use sha2::{Digest, Sha256};
use crate::http::types::{HttpRequest, HttpVersion};
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub struct Ja4hParts {
pub a: String,
pub b: String,
pub c: String,
pub d: String,
}
impl Ja4hParts {
pub fn fingerprint(&self) -> String {
format!("{}_{}_{}_{}", self.a, self.b, self.c, self.d)
}
}
pub fn ja4h(req: &HttpRequest) -> String {
ja4h_parts(req).fingerprint()
}
pub fn ja4h_parts(req: &HttpRequest) -> Ja4hParts {
let mut method_chars = String::new();
for &b in req.method.iter().take(2) {
method_chars.push(b.to_ascii_lowercase() as char);
}
while method_chars.len() < 2 {
method_chars.push('0');
}
let version = match req.version {
HttpVersion::Http1_0 => "10",
HttpVersion::Http1_1 => "11",
};
let mut has_cookie = false;
let mut has_referer = false;
let mut cookie_value: Option<&[u8]> = None;
let mut lang_raw: Option<String> = None;
let mut header_names: Vec<String> = Vec::new();
let total_headers = req.headers.len();
for (name, value) in &req.headers {
let lower_name: String = name
.iter()
.map(|b| b.to_ascii_lowercase() as char)
.collect();
match lower_name.as_str() {
"cookie" => {
has_cookie = true;
cookie_value = Some(value.as_ref());
continue;
}
"referer" => {
has_referer = true;
continue;
}
"accept-language" if lang_raw.is_none() => {
lang_raw = primary_lang(value);
}
_ => {}
}
header_names.push(lower_name);
}
let lang_code = lang_raw
.map(|s| pad_lang(&s))
.unwrap_or_else(|| "0000".to_string());
let header_count = total_headers.min(99);
let a = format!(
"{}{}{}{}{:02}{}",
method_chars,
version,
if has_cookie { "c" } else { "n" },
if has_referer { "r" } else { "n" },
header_count,
lang_code,
);
let b = sha256_first_12(header_names.join(",").as_bytes());
let (c, d) = match cookie_value {
None => ("000000000000".to_string(), "000000000000".to_string()),
Some(bytes) => {
let mut pairs = parse_cookies(bytes);
pairs.sort_by(|a, b| a.0.cmp(&b.0));
let names_csv = pairs
.iter()
.map(|(n, _)| n.as_str())
.collect::<Vec<_>>()
.join(",");
let pairs_csv = pairs
.iter()
.map(|(n, v)| format!("{n}={v}"))
.collect::<Vec<_>>()
.join(",");
if pairs.is_empty() {
("000000000000".to_string(), "000000000000".to_string())
} else {
(
sha256_first_12(names_csv.as_bytes()),
sha256_first_12(pairs_csv.as_bytes()),
)
}
}
};
Ja4hParts { a, b, c, d }
}
fn sha256_first_12(bytes: &[u8]) -> String {
let digest = Sha256::digest(bytes);
let hex = hex::encode(digest);
hex[..12].to_string()
}
fn parse_cookies(bytes: &[u8]) -> Vec<(String, String)> {
let s = match std::str::from_utf8(bytes) {
Ok(s) => s,
Err(_) => return Vec::new(),
};
s.split(';')
.filter_map(|cookie| {
let cookie = cookie.trim();
if cookie.is_empty() {
return None;
}
let (name, value) = cookie.split_once('=')?;
let name = name.trim();
if name.is_empty() {
return None;
}
Some((name.to_string(), value.trim().to_string()))
})
.collect()
}
fn primary_lang(value: &[u8]) -> Option<String> {
let s = std::str::from_utf8(value).ok()?;
let first = s.split(',').next()?.trim();
let stripped = first.split(';').next().unwrap_or(first).trim();
if stripped.is_empty() {
None
} else {
Some(stripped.to_ascii_lowercase())
}
}
fn pad_lang(raw: &str) -> String {
let cleaned: String = raw.chars().filter(|c| !matches!(*c, '-' | '_')).collect();
let mut out: String = cleaned.chars().take(4).collect();
while out.len() < 4 {
out.push('0');
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use bytes::Bytes;
fn req(method: &[u8], version: HttpVersion, headers: &[(&str, &str)]) -> HttpRequest {
HttpRequest {
method: Bytes::copy_from_slice(method),
path: Bytes::from_static(b"/"),
version,
headers: headers
.iter()
.map(|(k, v)| {
(
Bytes::copy_from_slice(k.as_bytes()),
Bytes::copy_from_slice(v.as_bytes()),
)
})
.collect(),
body: Bytes::new(),
}
}
#[test]
fn minimal_get_no_cookie_no_referer_no_lang() {
let r = req(b"GET", HttpVersion::Http1_1, &[("Host", "example.com")]);
let p = ja4h_parts(&r);
assert!(p.a.starts_with("ge11nn"));
assert_eq!(&p.a[6..8], "01");
assert!(p.a.ends_with("0000"));
assert_eq!(p.c, "000000000000");
assert_eq!(p.d, "000000000000");
}
#[test]
fn cookie_present_sets_c_marker_and_hashes() {
let r = req(
b"GET",
HttpVersion::Http1_1,
&[
("Host", "example.com"),
("Cookie", "session=abc; theme=dark"),
],
);
let p = ja4h_parts(&r);
assert_eq!(&p.a[4..5], "c");
assert_ne!(p.c, "000000000000");
assert_ne!(p.d, "000000000000");
assert_eq!(p.c.len(), 12);
assert_eq!(p.d.len(), 12);
}
#[test]
fn referer_present_sets_r_marker() {
let r = req(
b"GET",
HttpVersion::Http1_1,
&[("Host", "example.com"), ("Referer", "https://prev/")],
);
let p = ja4h_parts(&r);
assert_eq!(&p.a[5..6], "r");
}
#[test]
fn accept_language_picked_and_padded() {
let r = req(
b"GET",
HttpVersion::Http1_1,
&[
("Host", "example.com"),
("Accept-Language", "en-US,en;q=0.9"),
],
);
let p = ja4h_parts(&r);
assert!(p.a.ends_with("enus"), "expected enus suffix, got {}", p.a);
}
#[test]
fn accept_language_short_tag_padded_with_zeros() {
let r = req(
b"GET",
HttpVersion::Http1_1,
&[("Host", "example.com"), ("Accept-Language", "de")],
);
let p = ja4h_parts(&r);
assert!(p.a.ends_with("de00"));
}
#[test]
fn cookie_order_is_alphabetical_in_hashes() {
let r1 = req(
b"GET",
HttpVersion::Http1_1,
&[("Cookie", "zoo=zebra; alpha=apple; mid=middle")],
);
let r2 = req(
b"GET",
HttpVersion::Http1_1,
&[("Cookie", "alpha=apple; mid=middle; zoo=zebra")],
);
let p1 = ja4h_parts(&r1);
let p2 = ja4h_parts(&r2);
assert_eq!(p1.c, p2.c);
assert_eq!(p1.d, p2.d);
}
#[test]
fn deterministic_format() {
let r = req(
b"POST",
HttpVersion::Http1_1,
&[
("Host", "example.com"),
("Content-Type", "application/json"),
("Accept-Language", "fr-FR"),
],
);
let fp1 = ja4h(&r);
let fp2 = ja4h(&r);
assert_eq!(fp1, fp2);
assert!(fp1.starts_with("po11nn03frfr"));
let parts: Vec<&str> = fp1.split('_').collect();
assert_eq!(parts.len(), 4);
assert!(parts.iter().all(|p| p.len() == 12));
}
#[test]
fn header_count_caps_at_99() {
let mut headers: Vec<(String, String)> = Vec::new();
for i in 0..150 {
headers.push((format!("X-Custom-{i}"), "v".into()));
}
let r = HttpRequest {
method: Bytes::from_static(b"GET"),
path: Bytes::from_static(b"/"),
version: HttpVersion::Http1_1,
headers: headers
.iter()
.map(|(k, v)| {
(
Bytes::copy_from_slice(k.as_bytes()),
Bytes::copy_from_slice(v.as_bytes()),
)
})
.collect(),
body: Bytes::new(),
};
let p = ja4h_parts(&r);
assert_eq!(&p.a[6..8], "99");
}
#[test]
fn short_method_bytes_padded() {
let r = req(b"X", HttpVersion::Http1_1, &[("Host", "x")]);
let p = ja4h_parts(&r);
assert!(p.a.starts_with("x0"));
}
#[test]
fn http_1_0_version_section() {
let r = req(b"GET", HttpVersion::Http1_0, &[("Host", "x")]);
let p = ja4h_parts(&r);
assert_eq!(&p.a[2..4], "10");
}
}