use core::fmt;
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct Endpoint {
pub url: String,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub headers: HashMap<String, String>,
}
impl Endpoint {
pub fn new(url: impl Into<String>) -> Self {
Self {
url: url.into(),
headers: HashMap::new(),
}
}
pub fn with_header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
self.headers.insert(name.into(), value.into());
self
}
}
impl fmt::Debug for Endpoint {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Endpoint")
.field("url", &RedactedUrl(&self.url))
.field("headers", &RedactedHeaders(&self.headers))
.finish()
}
}
pub(crate) struct RedactedHeaders<'a>(pub(crate) &'a HashMap<String, String>);
impl fmt::Debug for RedactedHeaders<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let mut map = f.debug_map();
for name in self.0.keys() {
map.entry(name, &REDACTED);
}
map.finish()
}
}
pub(crate) struct RedactedUrl<'a>(pub(crate) &'a str);
impl fmt::Debug for RedactedUrl<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let without_userinfo = redact_userinfo(self.0);
let source = without_userinfo.as_deref().unwrap_or(self.0);
match redact_query_secrets(source) {
Some(redacted) => fmt::Debug::fmt(&redacted, f),
None => fmt::Debug::fmt(source, f),
}
}
}
fn redact_userinfo(url: &str) -> Option<String> {
let (prefix, rest) = match url.split_once("://") {
Some((scheme, rest)) => (&url[..scheme.len() + "://".len()], rest),
None => match url.strip_prefix("//") {
Some(rest) => ("//", rest),
None => ("", url),
},
};
let authority_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
let at = rest[..authority_end].rfind('@')?;
Some(format!("{prefix}{REDACTED}@{}", &rest[at + 1..]))
}
fn redact_query_secrets(url: &str) -> Option<String> {
let (head, fragment) = match url.split_once('#') {
Some((head, fragment)) => (head, Some(fragment)),
None => (url, None),
};
let (before, query) = match head.split_once('?') {
Some((before, query)) => (before, Some(query)),
None => (head, None),
};
let redacted_query = query.and_then(redact_pairs);
let redacted_fragment = fragment.and_then(redact_pairs);
if redacted_query.is_none() && redacted_fragment.is_none() {
return None;
}
let mut out = String::from(before);
if let Some(query) = redacted_query.as_deref().or(query) {
out.push('?');
out.push_str(query);
}
if let Some(fragment) = redacted_fragment.as_deref().or(fragment) {
out.push('#');
out.push_str(fragment);
}
Some(out)
}
fn redact_pairs(pairs: &str) -> Option<String> {
let mut redacted_any = false;
let mut out = String::with_capacity(pairs.len());
for (index, pair) in pairs.split('&').enumerate() {
if index > 0 {
out.push('&');
}
match pair.split_once('=') {
Some((name, _)) if names_secret(name) => {
redacted_any = true;
out.push_str(name);
out.push('=');
out.push_str(REDACTED);
}
_ => out.push_str(pair),
}
}
redacted_any.then_some(out)
}
const MAX_PERCENT_DECODE_ROUNDS: usize = 3;
fn names_secret(name: &str) -> bool {
if is_secret_param(name) {
return true;
}
let mut candidate = name.to_owned();
for _ in 0..MAX_PERCENT_DECODE_ROUNDS {
let Some(decoded) = percent_decode(&candidate) else {
return false;
};
if is_secret_param(&decoded) {
return true;
}
candidate = decoded;
}
false
}
fn percent_decode(value: &str) -> Option<String> {
if !value.contains('%') {
return None;
}
let bytes = value.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut decoded_any = false;
let mut index = 0;
while index < bytes.len() {
if bytes[index] == b'%' && index + 2 < bytes.len() {
if let (Some(high), Some(low)) =
(hex_digit(bytes[index + 1]), hex_digit(bytes[index + 2]))
{
out.push(high * 16 + low);
index += 3;
decoded_any = true;
continue;
}
}
out.push(bytes[index]);
index += 1;
}
if !decoded_any {
return None;
}
String::from_utf8(out).ok()
}
fn hex_digit(byte: u8) -> Option<u8> {
match byte {
b'0'..=b'9' => Some(byte - b'0'),
b'a'..=b'f' => Some(byte - b'a' + 10),
b'A'..=b'F' => Some(byte - b'A' + 10),
_ => None,
}
}
fn is_secret_param(name: &str) -> bool {
let normalized: String = name
.chars()
.filter(|c| *c != '-' && *c != '_')
.flat_map(char::to_lowercase)
.collect();
matches!(
normalized.as_str(),
"key"
| "apikey"
| "apisecret"
| "accesskey"
| "secretkey"
| "secret"
| "clientsecret"
| "token"
| "accesstoken"
| "idtoken"
| "refreshtoken"
| "sessiontoken"
| "password"
| "passwd"
| "pwd"
| "auth"
| "authorization"
| "sig"
| "signature"
| "subscriptionkey"
)
}
pub(crate) const REDACTED: &str = "<redacted>";
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn deserialize_with_headers() {
let raw = json!({
"url": "https://llm.internal/v1/chat",
"headers": {
"Authorization": "Bearer abc",
"X-Tenant": "acme",
}
});
let endpoint: Endpoint = serde_json::from_value(raw).unwrap();
assert_eq!(endpoint.url, "https://llm.internal/v1/chat");
assert_eq!(endpoint.headers.len(), 2);
assert_eq!(
endpoint.headers.get("Authorization").map(String::as_str),
Some("Bearer abc")
);
}
#[test]
fn deserialize_without_headers() {
let raw = json!({ "url": "https://example.com" });
let endpoint: Endpoint = serde_json::from_value(raw).unwrap();
assert!(endpoint.headers.is_empty());
}
#[test]
fn serialize_skips_empty_headers() {
let endpoint = Endpoint::new("https://example.com");
let json = serde_json::to_value(&endpoint).unwrap();
assert_eq!(json, json!({ "url": "https://example.com" }));
}
#[test]
fn with_header_chain() {
let endpoint = Endpoint::new("https://example.com")
.with_header("X-One", "1")
.with_header("X-Two", "2");
assert_eq!(endpoint.headers.len(), 2);
}
#[test]
fn debug_redacts_header_values_but_keeps_names_and_url() {
let endpoint = Endpoint::new("https://llm.internal/v1/chat")
.with_header("Authorization", "Bearer sk-live-0123456789")
.with_header("X-Tenant", "acme-tenant-id");
let debug = format!("{endpoint:?}");
assert!(!debug.contains("sk-live-0123456789"), "got: {debug}");
assert!(!debug.contains("acme-tenant-id"), "got: {debug}");
assert!(debug.contains("Authorization"), "got: {debug}");
assert!(debug.contains("X-Tenant"), "got: {debug}");
assert!(debug.contains("<redacted>"), "got: {debug}");
assert!(
debug.contains("https://llm.internal/v1/chat"),
"got: {debug}"
);
}
#[test]
fn debug_redacts_url_userinfo() {
let endpoint = Endpoint::new("https://alice:s3cret@llm.internal:8443/v1/chat?k=v");
let debug = format!("{endpoint:?}");
assert!(!debug.contains("s3cret"), "got: {debug}");
assert!(!debug.contains("alice"), "got: {debug}");
assert!(
debug.contains("llm.internal:8443/v1/chat?k=v"),
"got: {debug}"
);
assert!(debug.contains("<redacted>@"), "got: {debug}");
}
#[test]
fn debug_redacts_credentials_carried_in_the_query_string() {
for (url, secret) in [
(
"https://generativelanguage.googleapis.com/v1/x?key=AIzaSECRET",
"AIzaSECRET",
),
(
"https://acme.openai.azure.com/v1/chat?api-key=azSECRET",
"azSECRET",
),
("https://fn.internal/charge?token=FNSECRET&id=7", "FNSECRET"),
("https://s3.example/obj?signature=SIGSECRET", "SIGSECRET"),
("https://llm.internal/v1?Api_Key=MIXEDSECRET", "MIXEDSECRET"),
] {
let debug = format!("{:?}", Endpoint::new(url));
assert!(!debug.contains(secret), "leaked {secret} in: {debug}");
assert!(debug.contains("<redacted>"), "got: {debug}");
}
}
#[test]
fn debug_keeps_non_secret_query_parameters_and_the_rest_of_the_url() {
let debug = format!(
"{:?}",
Endpoint::new("https://llm.internal/v1/chat?model=gpt-4o&api-key=SECRET&stream=true")
);
assert!(!debug.contains("SECRET"), "got: {debug}");
assert!(debug.contains("model=gpt-4o"), "got: {debug}");
assert!(debug.contains("stream=true"), "got: {debug}");
assert!(debug.contains("api-key=<redacted>"), "got: {debug}");
assert!(
debug.contains("https://llm.internal/v1/chat?"),
"got: {debug}"
);
}
#[test]
fn debug_redacts_userinfo_and_a_query_secret_together() {
let debug = format!(
"{:?}",
Endpoint::new("https://alice:s3cret@llm.internal/v1/chat?api-key=qSECRET#frag")
);
assert!(!debug.contains("s3cret"), "got: {debug}");
assert!(!debug.contains("qSECRET"), "got: {debug}");
assert!(debug.contains("<redacted>@"), "got: {debug}");
assert!(debug.contains("api-key=<redacted>"), "got: {debug}");
assert!(debug.contains("#frag"), "got: {debug}");
}
#[test]
fn debug_keeps_a_url_without_userinfo_verbatim() {
for url in [
"https://llm.internal/v1/chat",
"https://llm.internal/v1/chat?to=a@b.com",
"llm.internal/v1/chat",
] {
let debug = format!("{:?}", Endpoint::new(url));
assert!(debug.contains(url), "got: {debug}");
}
}
#[test]
fn debug_redacts_userinfo_in_an_unparseable_url() {
let debug = format!("{:?}", Endpoint::new("wss://bob:hunter2@"));
assert!(!debug.contains("hunter2"), "got: {debug}");
assert!(!debug.contains("bob"), "got: {debug}");
}
#[test]
fn debug_redacts_userinfo_in_a_schemeless_url() {
for url in [
"bob:hunter2@llm.internal",
"bob:hunter2@llm.internal:8443/v1/chat?k=v",
"bob@llm.internal/v1/chat",
"//bob:hunter2@llm.internal/v1/chat",
] {
let debug = format!("{:?}", Endpoint::new(url));
assert!(!debug.contains("hunter2"), "got: {debug}");
assert!(!debug.contains("bob"), "got: {debug}");
assert!(debug.contains("<redacted>@"), "got: {debug}");
assert!(debug.contains("llm.internal"), "got: {debug}");
}
}
#[test]
fn debug_keeps_an_at_outside_a_schemeless_authority_verbatim() {
for url in [
"llm.internal/v1/chat?to=a@b.com",
"llm.internal/mail/a@b.com",
"/v1/chat?to=a@b.com",
"//llm.internal/v1/chat?to=a@b.com",
] {
let debug = format!("{:?}", Endpoint::new(url));
assert!(debug.contains(url), "got: {debug}");
}
}
#[test]
fn debug_redacts_a_percent_encoded_credential_parameter_name() {
for (url, secret, name) in [
(
"https://example.test/?api%2Dkey=SECRET1",
"SECRET1",
"api%2Dkey",
),
(
"https://example.test/?api%2dkey=SECRET2",
"SECRET2",
"api%2dkey",
),
("https://example.test/?%6Bey=SECRET3", "SECRET3", "%6Bey"),
(
"https://example.test/?Api%5Fkey=SECRET4",
"SECRET4",
"Api%5Fkey",
),
(
"https://example.test/?api%252Dkey=SECRET5",
"SECRET5",
"api%252Dkey",
),
(
"https://example.test/?%61%70%69%2d%6b%65%79=SECRET6",
"SECRET6",
"%61%70%69%2d%6b%65%79",
),
] {
let debug = format!("{:?}", Endpoint::new(url));
assert!(!debug.contains(secret), "leaked {secret} in: {debug}");
assert!(
debug.contains(&format!("{name}={REDACTED}")),
"expected the original name in: {debug}"
);
}
}
#[test]
fn debug_redacts_a_credential_carried_in_the_fragment() {
for (url, secret) in [
(
"https://example.test/cb#access_token=FRAGSECRET&state=xyz",
"FRAGSECRET",
),
(
"https://example.test/cb#api%2Dkey=FRAGSECRET2",
"FRAGSECRET2",
),
(
"https://example.test/cb?model=gpt-4o#token=FRAGSECRET3",
"FRAGSECRET3",
),
] {
let debug = format!("{:?}", Endpoint::new(url));
assert!(!debug.contains(secret), "leaked {secret} in: {debug}");
assert!(debug.contains(REDACTED), "got: {debug}");
}
let debug = format!(
"{:?}",
Endpoint::new("https://example.test/cb#access_token=FRAGSECRET&state=xyz")
);
assert!(debug.contains("state=xyz"), "got: {debug}");
assert!(debug.contains("https://example.test/cb#"), "got: {debug}");
}
#[test]
fn debug_keeps_a_percent_encoded_non_secret_parameter_verbatim() {
for url in [
"https://llm.internal/v1/chat?mo%64el=gpt-4o",
"https://llm.internal/v1/chat?api%2key=notdecodable",
"https://llm.internal/v1/chat?model=gpt-4o%",
] {
let debug = format!("{:?}", Endpoint::new(url));
assert!(debug.contains(url), "got: {debug}");
}
}
#[test]
fn debug_redacts_a_percent_encoded_credential_next_to_a_plain_one() {
let debug = format!(
"{:?}",
Endpoint::new(
"https://llm.internal/v1?model=gpt-4o&api%2Dkey=ENCSECRET&token=PLAINSECRET"
)
);
assert!(!debug.contains("ENCSECRET"), "got: {debug}");
assert!(!debug.contains("PLAINSECRET"), "got: {debug}");
assert!(debug.contains("model=gpt-4o"), "got: {debug}");
assert!(debug.contains("api%2Dkey=<redacted>"), "got: {debug}");
assert!(debug.contains("token=<redacted>"), "got: {debug}");
}
#[test]
fn debug_redaction_does_not_affect_serialization() {
let endpoint = Endpoint::new("https://example.com").with_header("Authorization", "secret");
let json = serde_json::to_value(&endpoint).unwrap();
assert_eq!(json["headers"]["Authorization"], "secret");
}
}