1use std::fmt;
2
3use crate::audit::hash12;
4use crate::trace::masked;
5
6pub struct Ja4hInput<'a> {
7 pub method: &'a str,
8 pub http_version: &'a str,
9 pub headers: &'a [(String, String)],
10}
11
12impl fmt::Debug for Ja4hInput<'_> {
13 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
14 f.debug_struct("Ja4hInput")
15 .field("method", &self.method)
16 .field("http_version", &self.http_version)
17 .field(
18 "headers",
19 &masked(self.headers.iter().map(|(k, v)| (k.as_str(), v.as_bytes()))),
20 )
21 .finish()
22 }
23}
24
25pub fn compute_ja4h(input: &Ja4hInput<'_>) -> String {
26 let a = input.section_a();
27 let b = input.section_b();
28 let c = input.section_c();
29 let d = input.section_d();
30 format!("{a}_{b}_{c}_{d}")
31}
32
33impl Ja4hInput<'_> {
34 fn section_a(&self) -> String {
35 let method = &self.method.to_lowercase();
36 let method2 = if method.len() >= 2 {
37 &method[..2]
38 } else {
39 method.as_str()
40 };
41
42 let version = match self.http_version {
43 "1.0" => "10",
44 "1.1" => "11",
45 "2" | "2.0" => "20",
46 "3" | "3.0" => "30",
47 _ => "00",
48 };
49
50 let has_cookie = self.headers.iter().any(|(n, _)| n == "cookie");
51 let has_referer = self.headers.iter().any(|(n, _)| n == "referer");
52 let cookie_flag = if has_cookie { "c" } else { "n" };
53 let referer_flag = if has_referer { "r" } else { "n" };
54
55 let count = self
56 .headers
57 .iter()
58 .filter(|(n, _)| n != "cookie" && n != "referer" && !n.starts_with(':'))
59 .count()
60 .min(99);
61
62 let lang = self
63 .headers
64 .iter()
65 .find(|(n, _)| n == "accept-language")
66 .map(|(_, v)| {
67 let first_tag = v.split(',').next().unwrap_or("");
68 let first_tag = first_tag.split(';').next().unwrap_or("").trim();
69 let cleaned: String = first_tag.chars().filter(|c| *c != '-').collect();
70 let mut s = cleaned;
71 while s.len() < 4 {
72 s.push('0');
73 }
74 s[..4].to_string()
75 })
76 .unwrap_or_else(|| "0000".to_string());
77
78 format!("{method2}{version}{cookie_flag}{referer_flag}{count:02}{lang}")
79 }
80
81 fn section_b(&self) -> String {
82 let mut names: Vec<&str> = self
83 .headers
84 .iter()
85 .map(|(n, _)| n.as_str())
86 .filter(|n| *n != "cookie" && *n != "referer" && !n.starts_with(':'))
87 .collect();
88 names.sort();
89 let s = names.join(",");
90 hash12(&s)
91 }
92
93 fn section_c(&self) -> String {
94 let cookie_val = self
95 .headers
96 .iter()
97 .find(|(n, _)| n == "cookie")
98 .map(|(_, v)| v.as_str());
99
100 match cookie_val {
101 Some(val) => {
102 let mut names: Vec<&str> = val
103 .split(';')
104 .filter_map(|pair| {
105 let pair = pair.trim();
106 pair.split('=').next().map(|n| n.trim())
107 })
108 .filter(|n| !n.is_empty())
109 .collect();
110 names.sort();
111 hash12(&names.join(","))
112 }
113 None => "000000000000".to_string(),
114 }
115 }
116
117 fn section_d(&self) -> String {
118 let cookie_val = self
119 .headers
120 .iter()
121 .find(|(n, _)| n == "cookie")
122 .map(|(_, v)| v.as_str());
123
124 match cookie_val {
125 Some(val) => {
126 let mut pairs: Vec<&str> = val
127 .split(';')
128 .map(|p| p.trim())
129 .filter(|p| !p.is_empty())
130 .collect();
131 pairs.sort_by_key(|p| p.split('=').next().unwrap_or(""));
132 hash12(&pairs.join(","))
133 }
134 None => "000000000000".to_string(),
135 }
136 }
137}
138
139#[cfg(test)]
140mod tests;