use std::collections::HashMap;
pub const DEFAULT_PACKET_COUNT: usize = 200;
const TCP_ACK_ONLY: u8 = 0x10;
#[derive(Debug, Clone)]
pub struct Ja4sshAccumulator {
packet_count: usize,
client_lens: Vec<u32>,
server_lens: Vec<u32>,
client_acks: u32,
server_acks: u32,
}
impl Default for Ja4sshAccumulator {
fn default() -> Self {
Self::new()
}
}
impl Ja4sshAccumulator {
pub fn new() -> Self {
Self::with_packet_count(DEFAULT_PACKET_COUNT)
}
pub fn with_packet_count(packet_count: usize) -> Self {
Self {
packet_count: packet_count.max(1),
client_lens: Vec::new(),
server_lens: Vec::new(),
client_acks: 0,
server_acks: 0,
}
}
pub fn record(&mut self, from_client: bool, payload_len: u32, tcp_flags: u8) -> Option<String> {
if payload_len > 0 {
if from_client {
self.client_lens.push(payload_len);
} else {
self.server_lens.push(payload_len);
}
} else if tcp_flags == TCP_ACK_ONLY {
if from_client {
self.client_acks += 1;
} else {
self.server_acks += 1;
}
}
if self.client_lens.len() + self.server_lens.len() >= self.packet_count {
Some(self.emit())
} else {
None
}
}
pub fn finish(&mut self) -> Option<String> {
if self.client_lens.is_empty()
&& self.server_lens.is_empty()
&& self.client_acks == 0
&& self.server_acks == 0
{
return None;
}
Some(self.emit())
}
fn emit(&mut self) -> String {
let fp = format!(
"c{}s{}_c{}s{}_c{}s{}",
mode(&self.client_lens),
mode(&self.server_lens),
self.client_lens.len(),
self.server_lens.len(),
self.client_acks,
self.server_acks,
);
self.client_lens.clear();
self.server_lens.clear();
self.client_acks = 0;
self.server_acks = 0;
fp
}
}
fn mode(values: &[u32]) -> u32 {
if values.is_empty() {
return 0;
}
let mut freqs: HashMap<u32, u32> = HashMap::new();
for &v in values {
*freqs.entry(v).or_insert(0) += 1;
}
let max = freqs.values().copied().max().unwrap_or(0);
freqs
.iter()
.filter(|&(_, &f)| f == max)
.map(|(&v, _)| v)
.min()
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn golden_window_c36s36_c76s124_c74s5() {
let mut acc = Ja4sshAccumulator::new();
for _ in 0..74 {
assert_eq!(acc.record(true, 0, TCP_ACK_ONLY), None);
}
for _ in 0..5 {
assert_eq!(acc.record(false, 0, TCP_ACK_ONLY), None);
}
for _ in 0..76 {
assert_eq!(acc.record(true, 36, 0x18), None);
}
for _ in 0..123 {
assert_eq!(acc.record(false, 36, 0x18), None);
}
let fp = acc.record(false, 36, 0x18).expect("window emits at 200");
assert_eq!(fp, "c36s36_c76s124_c74s5");
assert!(acc.finish().is_none());
}
#[test]
fn mode_ties_break_to_smaller() {
assert_eq!(mode(&[10, 20, 20, 10]), 10);
assert_eq!(mode(&[]), 0);
assert_eq!(mode(&[99]), 99);
assert_eq!(mode(&[5, 5, 5, 8]), 5);
}
#[test]
fn finish_flushes_partial_window() {
let mut acc = Ja4sshAccumulator::new();
acc.record(true, 40, 0x18);
acc.record(false, 88, 0x18);
acc.record(true, 0, TCP_ACK_ONLY);
let fp = acc.finish().expect("partial window flushed");
assert_eq!(fp, "c40s88_c1s1_c1s0");
assert!(acc.finish().is_none()); }
#[test]
fn non_ack_zero_payload_is_ignored() {
let mut acc = Ja4sshAccumulator::new();
assert_eq!(acc.record(true, 0, 0x02), None);
assert!(acc.finish().is_none());
}
#[test]
fn custom_window_size() {
let mut acc = Ja4sshAccumulator::with_packet_count(2);
assert_eq!(acc.record(true, 100, 0x18), None);
let fp = acc.record(false, 200, 0x18).expect("emits at 2");
assert_eq!(fp, "c100s200_c1s1_c0s0");
}
}