use reqwest::StatusCode;
use crate::error::Error as BaseError;
pub mod auth_reason {
pub const INVALID_CREDENTIALS: &str = "invalid_credentials";
pub const NOT_PAIRED: &str = "not_paired";
pub const PAIRING_WINDOW_CLOSED: &str = "pairing_window_closed";
pub const LOCKDOWN_MTLS_REQUIRED: &str = "lockdown_mtls_required";
pub const TRUST_SCORE_BLOCKED: &str = "trust_score_blocked";
pub const NO_CREDENTIALS: &str = "no_credentials";
}
pub fn seed_error_message(status: StatusCode, body: &str) -> String {
#[derive(serde::Deserialize)]
struct Envelope {
#[serde(default)]
error: String,
}
if let Ok(env) = serde_json::from_str::<Envelope>(body) {
if !env.error.is_empty() {
return env.error;
}
}
if !body.is_empty() {
return body.to_owned();
}
status.canonical_reason().unwrap_or("unknown").to_owned()
}
pub fn from_response(status: StatusCode, body: &str, endpoint_path: &str) -> BaseError {
let msg = seed_error_message(status, body);
let lower = msg.to_ascii_lowercase();
match status.as_u16() {
401 => BaseError::Auth(format!("{}: {msg}", auth_reason::INVALID_CREDENTIALS)),
403 => {
let reason = if lower.contains("not paired") {
auth_reason::NOT_PAIRED
} else if lower.contains("pairing window") {
auth_reason::PAIRING_WINDOW_CLOSED
} else if lower.contains("lockdown") || lower.contains("mtls") {
auth_reason::LOCKDOWN_MTLS_REQUIRED
} else if lower.contains("trust") || lower.contains("blocked") {
auth_reason::TRUST_SCORE_BLOCKED
} else {
auth_reason::INVALID_CREDENTIALS
};
BaseError::Auth(format!("{reason}: {msg}"))
}
400 | 405 | 422 => BaseError::Validation(msg),
404 => BaseError::NotFound(format!("{endpoint_path} (seed): {msg}")),
429 => BaseError::RateLimit {
retry_after_ms: 1000,
},
501 => BaseError::Validation(format!("not_implemented: {endpoint_path}: {msg}")),
503 => BaseError::Api {
code: 503,
message: format!("unavailable: {msg}"),
},
code => BaseError::Api { code, message: msg },
}
}
pub fn not_implemented(feature: &str) -> BaseError {
BaseError::Validation(format!(
"not_implemented: feature `{feature}` is reserved for Phase 1.5"
))
}
pub fn unsupported(reason: &str) -> BaseError {
BaseError::Validation(format!("unsupported: {reason}"))
}
pub fn config(reason: &str) -> BaseError {
BaseError::Validation(format!("config: {reason}"))
}
pub fn tls_pin(peer_host: &str) -> BaseError {
BaseError::Validation(format!("tls_pin: fingerprint mismatch for {peer_host}"))
}
pub fn trust_score_blocked(peer_url: &str) -> BaseError {
BaseError::Auth(format!("{}: {peer_url}", auth_reason::TRUST_SCORE_BLOCKED))
}
pub fn is_trust_score_blocked(err: &BaseError) -> bool {
matches!(err, BaseError::Auth(m) if m.starts_with(auth_reason::TRUST_SCORE_BLOCKED))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn seed_error_message_reads_envelope() {
let got = seed_error_message(StatusCode::FORBIDDEN, r#"{"error": "not paired"}"#);
assert_eq!(got, "not paired");
}
#[test]
fn seed_error_message_falls_back_to_body() {
let got = seed_error_message(StatusCode::BAD_REQUEST, "raw-bytes");
assert_eq!(got, "raw-bytes");
}
#[test]
fn seed_error_message_falls_back_to_status() {
let got = seed_error_message(StatusCode::BAD_REQUEST, "");
assert_eq!(got, "Bad Request");
}
#[test]
fn from_response_401_is_invalid_credentials() {
let err = from_response(
StatusCode::UNAUTHORIZED,
r#"{"error":"bad key"}"#,
"/api/v1/status",
);
match err {
BaseError::Auth(m) => assert!(m.starts_with(auth_reason::INVALID_CREDENTIALS)),
other => panic!("expected Auth, got {other:?}"),
}
}
#[test]
fn from_response_403_not_paired() {
let err = from_response(
StatusCode::FORBIDDEN,
r#"{"error":"not paired"}"#,
"/api/v1/store/ingest",
);
match err {
BaseError::Auth(m) => assert!(m.starts_with(auth_reason::NOT_PAIRED)),
other => panic!("expected Auth, got {other:?}"),
}
}
#[test]
fn from_response_404_includes_path() {
let err = from_response(StatusCode::NOT_FOUND, "", "/api/v1/unknown");
match err {
BaseError::NotFound(m) => assert!(m.contains("/api/v1/unknown")),
other => panic!("expected NotFound, got {other:?}"),
}
}
#[test]
fn from_response_501_marked_not_implemented() {
let err = from_response(StatusCode::NOT_IMPLEMENTED, "", "/api/v1/delta/stream");
match err {
BaseError::Validation(m) => {
assert!(m.contains("not_implemented"));
assert!(m.contains("/api/v1/delta/stream"));
}
other => panic!("expected Validation (not_implemented), got {other:?}"),
}
}
#[test]
fn trust_score_blocked_wraps_peer_url() {
let err = trust_score_blocked("https://s1:8443");
match err {
BaseError::Auth(ref m) => {
assert!(m.starts_with(auth_reason::TRUST_SCORE_BLOCKED));
assert!(m.contains("https://s1:8443"));
}
ref other => panic!("expected Auth, got {other:?}"),
}
assert!(is_trust_score_blocked(&err));
}
#[test]
fn is_trust_score_blocked_rejects_plain_auth() {
let err = BaseError::Auth("invalid_credentials: bad".into());
assert!(!is_trust_score_blocked(&err));
}
#[test]
fn unsupported_prefixes_validation() {
match unsupported("strong consistency unsupported; seed has no quorum") {
BaseError::Validation(m) => {
assert!(m.starts_with("unsupported:"));
assert!(m.contains("quorum"));
}
other => panic!("expected Validation, got {other:?}"),
}
}
#[test]
fn config_prefixes_validation() {
match config("peer not in mesh: https://x:8443") {
BaseError::Validation(m) => {
assert!(m.starts_with("config:"));
assert!(m.contains("peer not in mesh"));
}
other => panic!("expected Validation, got {other:?}"),
}
}
#[test]
fn tls_pin_prefixes_validation_with_host() {
match tls_pin("seed-a.local") {
BaseError::Validation(m) => {
assert!(m.starts_with("tls_pin:"));
assert!(m.contains("seed-a.local"));
assert!(m.contains("fingerprint mismatch"));
}
other => panic!("expected Validation, got {other:?}"),
}
}
}