use std::collections::BTreeMap;
use http::{HeaderName, HeaderValue};
use sha2::{Digest, Sha256};
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct SessionHash(String);
impl SessionHash {
pub fn new(
server_url: &str,
resource: Option<&str>,
headers: &std::collections::HashMap<HeaderName, HeaderValue>,
) -> Self {
let mut hasher = Sha256::new();
hasher.update(server_url.as_bytes());
hasher.update(b"|");
if let Some(r) = resource {
hasher.update(r.as_bytes());
}
hasher.update(b"|");
let sorted: BTreeMap<&str, &[u8]> = headers
.iter()
.map(|(k, v)| (k.as_str(), v.as_bytes()))
.collect();
for (name, value) in sorted {
hasher.update(name.as_bytes());
hasher.update(b":");
hasher.update(value);
hasher.update(b"\n");
}
let digest = hasher.finalize();
SessionHash(hex::encode(&digest[..8]))
}
#[allow(dead_code)] pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for SessionHash {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct CredentialKey(String);
impl CredentialKey {
pub fn new(server_url: &str, resource: Option<&str>) -> Self {
let normalized = normalize_server_url(server_url);
let mut hasher = Sha256::new();
hasher.update(normalized.as_bytes());
hasher.update(b"|");
if let Some(r) = resource {
hasher.update(r.as_bytes());
}
let digest = hasher.finalize();
CredentialKey(hex::encode(&digest[..8]))
}
#[allow(dead_code)] pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for CredentialKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
fn normalize_server_url(raw: &str) -> String {
let trimmed = raw.trim();
let Ok(mut u) = url::Url::parse(trimmed) else {
return trimmed.to_string();
};
u.set_query(None);
u.set_fragment(None);
if let Some(port) = u.port() {
let default = match u.scheme() {
"http" => Some(80),
"https" => Some(443),
_ => None,
};
if Some(port) == default {
let _ = u.set_port(None);
}
}
let new_path = {
let path = u.path();
let trimmed_path = path.trim_end_matches('/');
if trimmed_path.is_empty() {
"/".to_string()
} else {
trimmed_path.to_string()
}
};
if new_path != u.path() {
u.set_path(&new_path);
}
u.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
fn empty() -> HashMap<HeaderName, HeaderValue> {
HashMap::new()
}
#[test]
fn deterministic() {
let a = SessionHash::new("https://example.com/mcp", None, &empty());
let b = SessionHash::new("https://example.com/mcp", None, &empty());
assert_eq!(a, b);
}
#[test]
fn differs_on_resource() {
let a = SessionHash::new("https://example.com/mcp", None, &empty());
let b = SessionHash::new("https://example.com/mcp", Some("tenant-1"), &empty());
assert_ne!(a, b);
}
#[test]
fn differs_on_headers() {
let mut h = HashMap::new();
h.insert(
HeaderName::from_static("x-foo"),
HeaderValue::from_static("bar"),
);
let a = SessionHash::new("https://example.com/mcp", None, &empty());
let b = SessionHash::new("https://example.com/mcp", None, &h);
assert_ne!(a, b);
}
#[test]
fn header_order_does_not_matter() {
let mut h1 = HashMap::new();
h1.insert(
HeaderName::from_static("x-a"),
HeaderValue::from_static("1"),
);
h1.insert(
HeaderName::from_static("x-b"),
HeaderValue::from_static("2"),
);
let mut h2 = HashMap::new();
h2.insert(
HeaderName::from_static("x-b"),
HeaderValue::from_static("2"),
);
h2.insert(
HeaderName::from_static("x-a"),
HeaderValue::from_static("1"),
);
let a = SessionHash::new("https://example.com/mcp", None, &h1);
let b = SessionHash::new("https://example.com/mcp", None, &h2);
assert_eq!(a, b);
}
#[test]
fn hex_length_is_sixteen() {
let h = SessionHash::new("https://example.com/mcp", None, &empty());
assert_eq!(h.as_str().len(), 16);
assert!(h.as_str().chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn credential_key_is_deterministic() {
let a = CredentialKey::new("https://example.com/mcp", None);
let b = CredentialKey::new("https://example.com/mcp", None);
assert_eq!(a, b);
}
#[test]
fn credential_key_ignores_headers() {
let mut h = HashMap::new();
h.insert(
HeaderName::from_static("x-request-id"),
HeaderValue::from_static("abc"),
);
let s1 = SessionHash::new("https://example.com/mcp", None, &empty());
let s2 = SessionHash::new("https://example.com/mcp", None, &h);
assert_ne!(s1, s2, "sanity: SessionHash still differs on headers");
let k1 = CredentialKey::new("https://example.com/mcp", None);
let k2 = CredentialKey::new("https://example.com/mcp", None);
assert_eq!(k1, k2);
}
#[test]
fn credential_key_differs_on_resource() {
let a = CredentialKey::new("https://example.com/mcp", None);
let b = CredentialKey::new("https://example.com/mcp", Some("tenant-1"));
assert_ne!(a, b);
}
#[test]
fn credential_key_normalizes_trivial_url_variants() {
let canonical = CredentialKey::new("https://example.com/mcp", None);
for variant in [
"https://example.com/mcp/",
"https://example.com:443/mcp",
"https://Example.com/mcp",
"https://example.com/mcp?ignored=1",
"https://example.com/mcp#frag",
" https://example.com/mcp ",
] {
assert_eq!(
CredentialKey::new(variant, None),
canonical,
"variant {variant:?} should normalize to canonical form"
);
}
}
#[test]
fn credential_key_differs_on_path() {
let a = CredentialKey::new("https://example.com/mcp", None);
let b = CredentialKey::new("https://example.com/other", None);
assert_ne!(a, b);
}
#[test]
fn credential_key_falls_back_on_unparseable_input() {
let a = CredentialKey::new("not a url", None);
let b = CredentialKey::new("not a url", None);
assert_eq!(a, b);
assert_eq!(a.as_str().len(), 16);
}
#[test]
fn credential_key_hex_length_is_sixteen() {
let k = CredentialKey::new("https://example.com/mcp", None);
assert_eq!(k.as_str().len(), 16);
assert!(k.as_str().chars().all(|c| c.is_ascii_hexdigit()));
}
}