1use serde::{Deserialize, Serialize};
5use std::fmt;
6
7#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
20#[serde(transparent)]
21pub struct SessionId(String);
22
23impl SessionId {
24 pub fn parse(s: &str) -> Result<Self, crate::CoreError> {
27 if s.is_empty() {
28 return Err(crate::CoreError::InvalidId("session id is empty".to_owned()));
29 }
30 if s.len() > 128 {
31 return Err(crate::CoreError::InvalidId(format!(
40 "session id is {} bytes (max 128); starts {:?}",
41 s.len(),
42 &s[..s.floor_char_boundary(24.min(s.len()))],
43 )));
44 }
45 if !s.bytes().all(|b| (0x21..=0x7e).contains(&b)) {
46 return Err(crate::CoreError::InvalidId(format!(
50 "session id {s:?} contains bytes outside visible ASCII (0x21-0x7e)"
51 )));
52 }
53 Ok(Self(s.to_owned()))
54 }
55
56 pub fn as_str(&self) -> &str {
58 &self.0
59 }
60}
61
62impl<'de> Deserialize<'de> for SessionId {
63 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
64 use serde::de::Error as _;
65 let raw = String::deserialize(deserializer)?;
66 Self::parse(&raw).map_err(|error| D::Error::custom(error.to_string()))
67 }
68}
69
70impl fmt::Display for SessionId {
71 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
72 f.write_str(&self.0)
73 }
74}
75
76#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
82#[serde(transparent)]
83pub struct InstanceUid(String);
84
85impl InstanceUid {
86 pub fn parse(s: &str) -> Result<Self, crate::CoreError> {
89 if s.is_empty() {
90 return Err(crate::CoreError::InvalidId("instance uid is empty".to_owned()));
91 }
92 if s.len() > 128 {
93 return Err(crate::CoreError::InvalidId(format!(
95 "instance uid is {} bytes (max 128); starts {:?}",
96 s.len(),
97 &s[..s.floor_char_boundary(24.min(s.len()))],
98 )));
99 }
100 if !s.bytes().all(|b| (0x21..=0x7e).contains(&b)) {
101 return Err(crate::CoreError::InvalidId(format!(
102 "instance uid {s:?} contains bytes outside visible ASCII (0x21-0x7e)"
103 )));
104 }
105 Ok(Self(s.to_owned()))
106 }
107
108 pub fn as_str(&self) -> &str {
110 &self.0
111 }
112}
113
114impl<'de> Deserialize<'de> for InstanceUid {
115 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
116 use serde::de::Error as _;
117 let raw = String::deserialize(deserializer)?;
118 Self::parse(&raw).map_err(|error| D::Error::custom(error.to_string()))
119 }
120}
121
122impl fmt::Display for InstanceUid {
123 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
124 f.write_str(&self.0)
125 }
126}
127
128pub fn new_session_id() -> SessionId {
130 SessionId(uuid::Uuid::now_v7().to_string())
131}
132
133pub fn new_event_uid() -> String {
135 uuid::Uuid::now_v7().to_string()
136}
137
138#[cfg(test)]
139mod tests {
140 #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
141
142 use super::*;
143
144 #[test]
145 fn v7_ids_are_time_ordered() {
146 const N: usize = 64;
155 let mut ids: Vec<_> = (0..N).map(|_| new_event_uid()).collect();
156 for pair in ids.windows(2) {
161 if let [a, b] = pair {
162 assert!(a < b, "UUIDv7 must strictly sort by creation time: {a} vs {b}");
163 }
164 }
165 let sorted = ids.clone();
169 ids.sort_unstable();
170 assert_eq!(
171 ids, sorted,
172 "successive UUIDv7 calls must be strictly monotone without an external sort"
173 );
174 }
175
176 #[test]
177 fn session_id_rejects_bad_input() {
178 assert!(SessionId::parse("").is_err());
179 assert!(SessionId::parse("has space").is_err());
180 assert!(SessionId::parse("ctrl\x07char").is_err());
181 assert!(SessionId::parse(&"x".repeat(129)).is_err());
182 assert!(SessionId::parse("ok-id_123").is_ok());
183 }
184
185 #[test]
192 fn oversize_session_id_error_does_not_echo_the_full_input() {
193 let attacker_payload = "x".repeat(10_000);
194 let err = SessionId::parse(&attacker_payload).unwrap_err().to_string();
195 assert!(
196 err.len() < 128,
197 "over-length session id error must not embed the full input; got {} bytes",
198 err.len()
199 );
200 assert!(
201 err.contains("10000"),
202 "the length is the diagnostic operators need: {err}"
203 );
204 let uid_err = InstanceUid::parse(&attacker_payload).unwrap_err().to_string();
206 assert!(uid_err.len() < 128, "instance uid: got {} bytes", uid_err.len());
207 }
208
209 #[test]
214 fn oversize_session_id_error_survives_utf8_at_the_truncation_boundary() {
215 let mut input = "a".repeat(22);
219 input.push('€'); input.push_str(&"b".repeat(200));
221 let err = SessionId::parse(&input).unwrap_err().to_string();
225 assert!(err.len() < 128, "{err}");
226 }
227
228 #[test]
236 fn session_id_rejects_every_log_injection_byte() {
237 let mut hostile_bytes: Vec<u8> = (0..=0x20).collect();
239 hostile_bytes.push(0x7f);
240 hostile_bytes.extend(0x80u8..=0xff);
241 for b in hostile_bytes {
242 let s = format!("legit{}injected", b as char);
243 assert!(
244 SessionId::parse(&s).is_err(),
245 "byte 0x{b:02x} must not be allowed in a session id",
246 );
247 let leading = format!("{}suffix", b as char);
249 assert!(
250 SessionId::parse(&leading).is_err(),
251 "leading byte 0x{b:02x} must not be allowed",
252 );
253 }
254 }
255
256 #[test]
257 fn instance_uid_rejects_bad_input() {
258 assert!(InstanceUid::parse("").is_err());
259 assert!(InstanceUid::parse("é").is_err());
260 assert!(InstanceUid::parse("agent-7").is_ok());
261 }
262
263 #[test]
264 fn length_boundary_128_is_accepted_129_is_rejected() {
265 for parser in [
267 SessionId::parse("x".repeat(128).as_str()).is_ok(),
268 InstanceUid::parse("x".repeat(128).as_str()).is_ok(),
269 ] {
270 assert!(parser, "128-char id must be accepted");
271 }
272 assert!(SessionId::parse(&"x".repeat(129)).is_err());
273 assert!(InstanceUid::parse(&"x".repeat(129)).is_err());
274 }
275
276 #[test]
277 fn as_str_and_display_return_the_wrapped_string() {
278 let sid = SessionId::parse("sess-abc-123").unwrap();
280 assert_eq!(sid.as_str(), "sess-abc-123");
281 assert_eq!(format!("{sid}"), "sess-abc-123");
282 let iid = InstanceUid::parse("inst-42").unwrap();
283 assert_eq!(iid.as_str(), "inst-42");
284 assert_eq!(format!("{iid}"), "inst-42");
285 }
286
287 #[test]
288 fn new_event_uid_returns_a_uuid_shaped_string() {
289 let uid = new_event_uid();
291 assert_eq!(uid.len(), 36, "UUID text is 36 chars: {uid:?}");
292 assert_eq!(uid.matches('-').count(), 4, "UUID has 4 hyphens: {uid:?}");
293 }
294
295 #[test]
302 fn session_id_deserialize_rejects_hostile_wire_input() {
303 let empty = serde_json::from_str::<SessionId>(r#""""#);
306 assert!(empty.is_err(), "empty id must be rejected on deserialize");
307 let crlf = serde_json::from_str::<SessionId>(r#""a\r\nfake-log""#);
310 assert!(crlf.is_err(), "CRLF must be rejected on deserialize");
311 let rtl = serde_json::from_str::<SessionId>(r#""\u202Elegit""#);
313 assert!(rtl.is_err(), "non-ASCII must be rejected on deserialize");
314 let too_long = format!(r#""{}""#, "x".repeat(129));
316 assert!(
317 serde_json::from_str::<SessionId>(&too_long).is_err(),
318 "> 128 chars must be rejected on deserialize"
319 );
320 }
321
322 #[test]
323 fn instance_uid_deserialize_rejects_hostile_wire_input() {
324 let empty = serde_json::from_str::<InstanceUid>(r#""""#);
325 assert!(empty.is_err(), "empty instance_uid must be rejected");
326 let non_ascii = serde_json::from_str::<InstanceUid>(r#""agent-é""#);
327 assert!(non_ascii.is_err(), "non-ASCII instance_uid must be rejected");
328 }
329
330 #[test]
333 fn session_id_valid_deserialize_round_trip() {
334 let id = SessionId::parse("sess-abc-123").unwrap();
335 let wire = serde_json::to_string(&id).unwrap();
336 assert_eq!(wire, r#""sess-abc-123""#);
337 let restored: SessionId = serde_json::from_str(&wire).unwrap();
338 assert_eq!(restored, id);
339 }
340}