1use std::io::Write;
11use std::net::IpAddr;
12
13use serde::{Deserialize, Serialize};
14
15use crate::audit::{fingerprint, iso_now};
16
17#[derive(Debug, Serialize, Deserialize, Clone)]
22pub struct SessionLogEntry {
23 pub timestamp: String,
25 pub event: String,
27 pub session_id: String,
29 pub turn_count: usize,
31 pub risk_trajectory: Vec<String>,
33 pub current_risk: String,
35 pub historical_mean: f64,
37 pub client_ip_masked: String,
39 pub input_fingerprint: String,
41}
42
43impl SessionLogEntry {
44 pub fn new(
45 session_id: String,
46 turn_count: usize,
47 risk_trajectory: Vec<String>,
48 historical_mean: f64,
49 client_ip: &IpAddr,
50 user_input: &str,
51 ) -> Self {
52 let current_risk = risk_trajectory
53 .last()
54 .cloned()
55 .unwrap_or_else(|| "unknown".into());
56 Self {
57 timestamp: iso_now(),
58 event: "escalation_detected".into(),
59 session_id,
60 turn_count,
61 risk_trajectory,
62 current_risk,
63 historical_mean,
64 client_ip_masked: mask_ip(client_ip),
65 input_fingerprint: fingerprint(user_input.as_bytes()),
66 }
67 }
68}
69
70pub fn mask_ip(ip: &IpAddr) -> String {
75 match ip {
76 IpAddr::V4(v4) => {
77 let o = v4.octets();
78 format!("{}.{}.x.x", o[0], o[1])
79 }
80 IpAddr::V6(v6) => {
81 let s = v6.segments();
82 format!("{:x}:{:x}:{:x}:{:x}:x:x:x:x", s[0], s[1], s[2], s[3])
83 }
84 }
85}
86
87pub fn append(path: &str, entry: &SessionLogEntry) -> std::io::Result<()> {
92 let mut line = serde_json::to_string(entry).map_err(std::io::Error::other)?;
93 line.push('\n');
94 let mut file = std::fs::OpenOptions::new()
95 .create(true)
96 .append(true)
97 .open(path)?;
98 file.write_all(line.as_bytes())
99}
100
101pub fn read_all(path: &str) -> std::io::Result<Vec<SessionLogEntry>> {
106 let raw = std::fs::read_to_string(path)?;
107 let entries = raw
108 .lines()
109 .filter(|l| !l.trim().is_empty())
110 .filter_map(|l| serde_json::from_str::<SessionLogEntry>(l).ok())
111 .collect();
112 Ok(entries)
113}
114
115#[cfg(test)]
120mod tests {
121 use super::*;
122 use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
123
124 #[test]
125 fn mask_ipv4_zeros_last_two_octets() {
126 let ip = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 50));
127 assert_eq!(mask_ip(&ip), "192.168.x.x");
128 }
129
130 #[test]
131 fn mask_ipv4_loopback() {
132 let ip = IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1));
133 assert_eq!(mask_ip(&ip), "127.0.x.x");
134 }
135
136 #[test]
137 fn mask_ipv6_zeros_last_four_groups() {
138 let ip = IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 1, 2, 3, 4));
139 let masked = mask_ip(&ip);
140 assert!(masked.starts_with("2001:db8:0:0:"), "got: {masked}");
141 assert!(masked.ends_with(":x:x:x:x"), "got: {masked}");
142 }
143
144 #[test]
145 fn append_and_read_roundtrip() {
146 let dir = tempfile::tempdir().unwrap();
147 let path = dir.path().join("session.jsonl");
148 let path_str = path.to_str().unwrap();
149
150 let ip = IpAddr::V4(Ipv4Addr::new(10, 0, 1, 2));
151 let entry = SessionLogEntry::new(
152 "sbh-s-42".into(),
153 3,
154 vec!["low".into(), "low".into(), "high".into()],
155 0.0,
156 &ip,
157 "test input that triggered escalation",
158 );
159
160 append(path_str, &entry).unwrap();
161 append(path_str, &entry).unwrap();
162
163 let entries = read_all(path_str).unwrap();
164 assert_eq!(entries.len(), 2);
165 assert_eq!(entries[0].event, "escalation_detected");
166 assert_eq!(entries[0].session_id, "sbh-s-42");
167 assert_eq!(entries[0].turn_count, 3);
168 assert_eq!(entries[0].current_risk, "high");
169 assert_eq!(entries[0].risk_trajectory, vec!["low", "low", "high"]);
170 assert_eq!(entries[0].client_ip_masked, "10.0.x.x");
171 assert!(!entries[0].input_fingerprint.is_empty());
172 }
173
174 #[test]
175 fn fingerprint_is_stable_across_entries() {
176 let ip = IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4));
177 let input = "same input every time";
178 let e1 = SessionLogEntry::new(
179 "s1".into(),
180 3,
181 vec!["low".into(), "low".into(), "high".into()],
182 0.0,
183 &ip,
184 input,
185 );
186 let e2 = SessionLogEntry::new(
187 "s2".into(),
188 4,
189 vec!["low".into(), "low".into(), "low".into(), "high".into()],
190 0.0,
191 &ip,
192 input,
193 );
194 assert_eq!(e1.input_fingerprint, e2.input_fingerprint);
195 }
196
197 #[test]
198 fn new_sets_current_risk_from_trajectory() {
199 let ip = IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1));
200 let entry = SessionLogEntry::new(
201 "s".into(),
202 3,
203 vec!["low".into(), "medium".into(), "high".into()],
204 0.5,
205 &ip,
206 "x",
207 );
208 assert_eq!(entry.current_risk, "high");
209 }
210
211 #[test]
212 fn new_empty_trajectory_current_risk_unknown() {
213 let ip = IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1));
214 let entry = SessionLogEntry::new("s".into(), 0, vec![], 0.0, &ip, "x");
215 assert_eq!(entry.current_risk, "unknown");
216 }
217}