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