use std::fmt::Write as _;
use crate::api::request::RequestTarget;
use crate::error::{Result, XurlError};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum AuthScheme {
Bearer,
OAuth1User,
OAuth2User(&'static [&'static str]),
}
impl AuthScheme {
#[must_use]
pub const fn wire(self) -> WireScheme {
match self {
Self::Bearer => WireScheme::App,
Self::OAuth1User => WireScheme::OAuth1,
Self::OAuth2User(_) => WireScheme::OAuth2,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum WireScheme {
App,
OAuth1,
OAuth2,
}
impl WireScheme {
pub const ALL_BY_PREFERENCE: [Self; 3] = [Self::OAuth2, Self::OAuth1, Self::App];
#[must_use]
pub const fn as_wire(self) -> &'static str {
match self {
Self::App => "app",
Self::OAuth1 => "oauth1",
Self::OAuth2 => "oauth2",
}
}
#[must_use]
pub const fn pretty(self) -> &'static str {
match self {
Self::App => "Bearer (app)",
Self::OAuth1 => "OAuth 1.0a",
Self::OAuth2 => "OAuth 2.0",
}
}
#[must_use]
pub fn from_wire(s: &str) -> Option<Self> {
match s {
"app" | "App" | "APP" => Some(Self::App),
"oauth1" | "OAuth1" | "OAUTH1" => Some(Self::OAuth1),
"oauth2" | "OAuth2" | "OAUTH2" => Some(Self::OAuth2),
other if other.eq_ignore_ascii_case("app") => Some(Self::App),
other if other.eq_ignore_ascii_case("oauth1") => Some(Self::OAuth1),
other if other.eq_ignore_ascii_case("oauth2") => Some(Self::OAuth2),
_ => None,
}
}
}
#[allow(clippy::all)]
#[allow(missing_docs)]
mod generated {
use super::AuthScheme;
include!(concat!(env!("OUT_DIR"), "/auth_matrix.rs"));
}
pub use generated::{AUTH_MATRIX, SHORTCUT_TEMPLATES};
const MAX_METHOD_LEN: usize = 8;
#[must_use]
pub fn supported_auth(method: &str, path: &str) -> Option<&'static [AuthScheme]> {
let bytes = method.as_bytes();
if bytes.is_empty() || bytes.len() > MAX_METHOD_LEN {
return None;
}
let mut buf = [0u8; MAX_METHOD_LEN];
for (i, &b) in bytes.iter().enumerate() {
if !b.is_ascii() {
return None;
}
buf[i] = b.to_ascii_uppercase();
}
let upper = std::str::from_utf8(&buf[..bytes.len()]).expect("ASCII upper is valid UTF-8");
let mut key = String::with_capacity(upper.len() + 1 + path.len());
write!(&mut key, "{upper}\0{path}").expect("write to String never fails");
AUTH_MATRIX.get(key.as_str()).copied()
}
#[must_use]
pub fn auth_scheme_wire_str(scheme: AuthScheme) -> &'static str {
scheme.wire().as_wire()
}
pub(crate) fn schemes_to_wire_list(schemes: &[AuthScheme]) -> Vec<&'static str> {
let mut out: Vec<&'static str> = Vec::with_capacity(schemes.len());
for s in schemes {
let w = s.wire().as_wire();
if !out.contains(&w) {
out.push(w);
}
}
out
}
pub fn validate(
target: &RequestTarget,
method: &str,
requested_auth: &str,
app: Option<&str>,
) -> Result<()> {
let RequestTarget::Template {
path, path_params, ..
} = target
else {
return Ok(());
};
let Some(schemes) = supported_auth(method, path) else {
return Ok(());
};
if requested_auth.is_empty() {
return Ok(());
}
let requested_norm = requested_auth.to_ascii_lowercase();
let static_supported = schemes_to_wire_list(schemes);
if static_supported.iter().any(|s| *s == requested_norm) {
return Ok(());
}
let supported: Vec<String> = static_supported.iter().map(|s| (*s).to_string()).collect();
let rendered_url = crate::api::request::render_template_path(path, path_params).ok();
Err(XurlError::AuthMethodMismatch {
endpoint: path.clone(),
rendered_url,
method: method.to_ascii_uppercase(),
requested: Some(requested_norm),
supported,
available_in_app: None,
app: app.map(str::to_string),
other_apps_with_creds: None,
})
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use super::{AUTH_MATRIX, AuthScheme, SHORTCUT_TEMPLATES, supported_auth, validate};
use crate::api::request::RequestTarget;
use crate::error::XurlError;
const EXPECTED_SHORTCUT_COUNT: usize = 32;
#[test]
fn shortcut_templates_anchor_count() {
assert_eq!(
SHORTCUT_TEMPLATES.len(),
EXPECTED_SHORTCUT_COUNT,
"SHORTCUT_TEMPLATES drifted from the locked-in shortcut count"
);
}
#[test]
fn matrix_has_at_least_one_entry_per_allowlist_pair() {
assert!(
!AUTH_MATRIX.is_empty(),
"AUTH_MATRIX is empty — build-time codegen produced nothing"
);
assert!(
AUTH_MATRIX.len() >= EXPECTED_SHORTCUT_COUNT - 2,
"AUTH_MATRIX has {} entries, expected at least {}",
AUTH_MATRIX.len(),
EXPECTED_SHORTCUT_COUNT - 2,
);
}
#[test]
fn supported_auth_media_upload_post() {
let schemes = supported_auth("POST", "/2/media/upload")
.expect("/2/media/upload POST must be in the matrix");
assert_eq!(
schemes,
&[
AuthScheme::OAuth2User(&["media.write"]),
AuthScheme::OAuth1User,
],
"media upload init must accept OAuth2 (media.write) + OAuth1"
);
}
#[test]
fn supported_auth_delete_tweet() {
let schemes = supported_auth("DELETE", "/2/tweets/{id}")
.expect("DELETE /2/tweets/{{id}} must be in the matrix");
assert!(
!schemes.is_empty(),
"DELETE /2/tweets/{{id}} must declare at least one scheme"
);
assert!(
schemes.contains(&AuthScheme::OAuth1User),
"DELETE /2/tweets/{{id}} must accept OAuth1 user context"
);
}
#[test]
fn literal_vs_param_no_precedence_resolution() {
let me = supported_auth("GET", "/2/users/me");
assert!(me.is_some(), "/2/users/me must be in the matrix");
let bare = supported_auth("GET", "/2/users/{id}");
assert!(
bare.is_none(),
"lookup must be a direct hash hit, no path-template precedence resolution"
);
}
#[test]
fn unknown_path_returns_none() {
assert_eq!(supported_auth("POST", "/2/never/heard/of"), None);
}
#[test]
fn unknown_method_returns_none() {
assert_eq!(supported_auth("PATCH", "/2/tweets"), None);
}
#[test]
fn method_case_insensitive() {
let upper = supported_auth("POST", "/2/tweets");
let lower = supported_auth("post", "/2/tweets");
assert!(upper.is_some());
assert_eq!(upper, lower, "method comparison must be case-insensitive");
}
#[test]
fn supported_auth_empty_method_returns_none() {
assert_eq!(supported_auth("", "/2/tweets"), None);
}
#[test]
fn supported_auth_oversize_method_returns_none() {
assert_eq!(supported_auth("PROPPATCH", "/2/tweets"), None);
}
#[test]
fn supported_auth_non_ascii_method_returns_none() {
assert_eq!(supported_auth("GÉT", "/2/tweets"), None);
}
fn tmpl(path: &str) -> RequestTarget {
RequestTarget::Template {
path: path.to_string(),
path_params: HashMap::new(),
query: Vec::new(),
}
}
#[test]
fn validate_raw_url_always_ok() {
let target = RequestTarget::RawUrl("https://api.x.com/2/media/upload".to_string());
assert!(validate(&target, "POST", "app", None).is_ok());
assert!(validate(&target, "GET", "oauth1", None).is_ok());
assert!(validate(&target, "DELETE", "", None).is_ok());
}
#[test]
fn validate_template_empty_requested_is_ok() {
let target = tmpl("/2/media/upload");
assert!(validate(&target, "POST", "", None).is_ok());
}
#[test]
fn validate_template_matrix_miss_is_ok() {
let target = tmpl("/2/never/heard/of");
assert!(validate(&target, "POST", "app", None).is_ok());
assert!(validate(&target, "GET", "oauth2", None).is_ok());
}
#[test]
fn validate_template_requested_in_supported_is_ok() {
let target = tmpl("/2/media/upload");
assert!(validate(&target, "POST", "oauth1", None).is_ok());
assert!(validate(&target, "POST", "oauth2", None).is_ok());
}
#[test]
fn validate_template_requested_case_insensitive() {
let target = tmpl("/2/media/upload");
assert!(validate(&target, "post", "OAuth1", None).is_ok());
}
#[test]
fn validate_template_requested_not_in_supported_errors() {
let target = tmpl("/2/media/upload");
let err = validate(&target, "POST", "app", None).unwrap_err();
match err {
XurlError::AuthMethodMismatch {
endpoint,
method,
requested,
supported,
available_in_app,
..
} => {
assert_eq!(endpoint, "/2/media/upload");
assert_eq!(method, "POST");
assert_eq!(requested.as_deref(), Some("app"));
assert_eq!(supported, vec!["oauth2".to_string(), "oauth1".to_string()]);
assert!(
available_in_app.is_none(),
"explicit-mismatch shape must leave `available_in_app` at None"
);
}
other => panic!("expected AuthMethodMismatch, got {other:?}"),
}
}
#[test]
fn validate_envelope_message_lists_alternatives() {
let target = tmpl("/2/media/upload");
let err = validate(&target, "POST", "app", None).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("Bearer (app)"),
"message must pretty-print requested scheme: {msg}"
);
assert!(
msg.contains("--auth oauth2") && msg.contains("--auth oauth1"),
"message must list both supported alternatives: {msg}"
);
assert!(
msg.contains("POST /2/media/upload"),
"message must include method + endpoint: {msg}"
);
}
}