use std::collections::hash_map::RandomState;
use std::hash::{BuildHasher, Hasher};
use std::sync::OnceLock;
use axum::http::{header::AUTHORIZATION, HeaderMap};
pub(crate) const CLIENT_HEADER: &str = "x-ferrox-client";
const MAX_CLIENT_LEN: usize = 32;
fn salt() -> &'static RandomState {
static SALT: OnceLock<RandomState> = OnceLock::new();
SALT.get_or_init(RandomState::new)
}
pub(crate) fn key_fingerprint(key: &str) -> String {
let mut hasher = salt().build_hasher();
hasher.write(key.as_bytes());
format!("key-{:08x}", hasher.finish() as u32)
}
fn sanitize_client(raw: &str) -> Option<String> {
let cleaned: String = raw
.chars()
.filter(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | ':' | '/' | ' '))
.take(MAX_CLIENT_LEN)
.collect();
let trimmed = cleaned.trim();
(!trimmed.is_empty()).then(|| trimmed.to_string())
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct Attribution {
pub(crate) via_api_key: Option<String>,
pub(crate) client: Option<String>,
}
impl Attribution {
pub(crate) fn from_headers(headers: &HeaderMap) -> Self {
let via_api_key = headers
.get(AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.map(str::trim)
.filter(|k| !k.is_empty())
.map(key_fingerprint);
let client = headers
.get(CLIENT_HEADER)
.and_then(|v| v.to_str().ok())
.and_then(sanitize_client);
Attribution {
via_api_key,
client,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use axum::http::HeaderValue;
fn headers(pairs: &[(&str, &str)]) -> HeaderMap {
let mut map = HeaderMap::new();
for (name, value) in pairs {
map.insert(
axum::http::HeaderName::from_bytes(name.as_bytes()).unwrap(),
HeaderValue::from_str(value).unwrap(),
);
}
map
}
#[test]
fn the_same_key_fingerprints_the_same_and_a_different_key_does_not() {
assert_eq!(key_fingerprint("sk-abc"), key_fingerprint("sk-abc"));
assert_ne!(key_fingerprint("sk-abc"), key_fingerprint("sk-abd"));
}
#[test]
fn a_fingerprint_never_contains_the_key_it_names() {
let key = "sk-super-secret-value";
let fp = key_fingerprint(key);
assert!(!fp.contains(key));
assert!(!fp.contains("secret"));
assert!(fp.starts_with("key-"));
assert_eq!(fp.len(), "key-".len() + 8);
}
#[test]
fn a_bearer_header_is_recorded_as_a_fingerprint_and_nothing_else() {
let attr = Attribution::from_headers(&headers(&[("authorization", "Bearer sk-abc")]));
assert_eq!(attr.via_api_key, Some(key_fingerprint("sk-abc")));
assert_eq!(attr.client, None);
}
#[test]
fn no_authorization_header_means_no_fingerprint() {
assert_eq!(
Attribution::from_headers(&HeaderMap::new()).via_api_key,
None
);
let empty = Attribution::from_headers(&headers(&[("authorization", "Bearer ")]));
assert_eq!(empty.via_api_key, None, "an empty key is not a key");
let basic = Attribution::from_headers(&headers(&[("authorization", "Basic abc")]));
assert_eq!(
basic.via_api_key, None,
"only Bearer is this server's scheme"
);
}
#[test]
fn a_client_label_is_kept_verbatim_when_it_is_already_a_label() {
let attr = Attribution::from_headers(&headers(&[("x-ferrox-client", "ferrox-studio")]));
assert_eq!(attr.client.as_deref(), Some("ferrox-studio"));
}
#[test]
fn a_hostile_client_label_is_cut_down_to_a_label() {
let attr = Attribution::from_headers(&headers(&[(
"x-ferrox-client",
"<img src=x onerror=alert(1)>",
)]));
let client = attr.client.expect("something survives");
assert!(!client.contains('<'), "{client}");
assert!(!client.contains('>'), "{client}");
assert!(!client.contains('='), "{client}");
assert!(!client.contains('('), "{client}");
let long = "a".repeat(4096);
let attr = Attribution::from_headers(&headers(&[("x-ferrox-client", &long)]));
assert_eq!(attr.client.map(|c| c.len()), Some(MAX_CLIENT_LEN));
}
#[test]
fn a_label_that_is_only_junk_is_absent_rather_than_empty() {
let attr = Attribution::from_headers(&headers(&[("x-ferrox-client", "<<<>>>")]));
assert_eq!(attr.client, None);
let attr = Attribution::from_headers(&headers(&[("x-ferrox-client", " ")]));
assert_eq!(attr.client, None);
}
}