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() || s.len() > 128 || !s.bytes().all(|b| (0x21..=0x7e).contains(&b)) {
28 return Err(crate::CoreError::InvalidId(format!("session id {s:?}")));
29 }
30 Ok(Self(s.to_owned()))
31 }
32
33 pub fn as_str(&self) -> &str {
35 &self.0
36 }
37}
38
39impl<'de> Deserialize<'de> for SessionId {
40 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
41 use serde::de::Error as _;
42 let raw = String::deserialize(deserializer)?;
43 Self::parse(&raw).map_err(|error| D::Error::custom(error.to_string()))
44 }
45}
46
47impl fmt::Display for SessionId {
48 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
49 f.write_str(&self.0)
50 }
51}
52
53#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
59#[serde(transparent)]
60pub struct InstanceUid(String);
61
62impl InstanceUid {
63 pub fn parse(s: &str) -> Result<Self, crate::CoreError> {
66 if s.is_empty() || s.len() > 128 || !s.bytes().all(|b| (0x21..=0x7e).contains(&b)) {
67 return Err(crate::CoreError::InvalidId(format!("instance uid {s:?}")));
68 }
69 Ok(Self(s.to_owned()))
70 }
71
72 pub fn as_str(&self) -> &str {
74 &self.0
75 }
76}
77
78impl<'de> Deserialize<'de> for InstanceUid {
79 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
80 use serde::de::Error as _;
81 let raw = String::deserialize(deserializer)?;
82 Self::parse(&raw).map_err(|error| D::Error::custom(error.to_string()))
83 }
84}
85
86impl fmt::Display for InstanceUid {
87 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
88 f.write_str(&self.0)
89 }
90}
91
92pub fn new_session_id() -> SessionId {
94 SessionId(uuid::Uuid::now_v7().to_string())
95}
96
97pub fn new_event_uid() -> String {
99 uuid::Uuid::now_v7().to_string()
100}
101
102#[cfg(test)]
103mod tests {
104 #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
105
106 use super::*;
107
108 #[test]
109 fn v7_ids_are_time_ordered() {
110 let a = new_event_uid();
111 let b = new_event_uid();
112 assert!(a <= b, "UUIDv7 must sort by creation time: {a} vs {b}");
113 }
114
115 #[test]
116 fn session_id_rejects_bad_input() {
117 assert!(SessionId::parse("").is_err());
118 assert!(SessionId::parse("has space").is_err());
119 assert!(SessionId::parse("ctrl\x07char").is_err());
120 assert!(SessionId::parse(&"x".repeat(129)).is_err());
121 assert!(SessionId::parse("ok-id_123").is_ok());
122 }
123
124 #[test]
132 fn session_id_rejects_every_log_injection_byte() {
133 let mut hostile_bytes: Vec<u8> = (0..=0x20).collect();
135 hostile_bytes.push(0x7f);
136 hostile_bytes.extend(0x80u8..=0xff);
137 for b in hostile_bytes {
138 let s = format!("legit{}injected", b as char);
139 assert!(
140 SessionId::parse(&s).is_err(),
141 "byte 0x{b:02x} must not be allowed in a session id",
142 );
143 let leading = format!("{}suffix", b as char);
145 assert!(
146 SessionId::parse(&leading).is_err(),
147 "leading byte 0x{b:02x} must not be allowed",
148 );
149 }
150 }
151
152 #[test]
153 fn instance_uid_rejects_bad_input() {
154 assert!(InstanceUid::parse("").is_err());
155 assert!(InstanceUid::parse("é").is_err());
156 assert!(InstanceUid::parse("agent-7").is_ok());
157 }
158
159 #[test]
160 fn length_boundary_128_is_accepted_129_is_rejected() {
161 for parser in [
163 SessionId::parse("x".repeat(128).as_str()).is_ok(),
164 InstanceUid::parse("x".repeat(128).as_str()).is_ok(),
165 ] {
166 assert!(parser, "128-char id must be accepted");
167 }
168 assert!(SessionId::parse(&"x".repeat(129)).is_err());
169 assert!(InstanceUid::parse(&"x".repeat(129)).is_err());
170 }
171
172 #[test]
173 fn as_str_and_display_return_the_wrapped_string() {
174 let sid = SessionId::parse("sess-abc-123").unwrap();
176 assert_eq!(sid.as_str(), "sess-abc-123");
177 assert_eq!(format!("{sid}"), "sess-abc-123");
178 let iid = InstanceUid::parse("inst-42").unwrap();
179 assert_eq!(iid.as_str(), "inst-42");
180 assert_eq!(format!("{iid}"), "inst-42");
181 }
182
183 #[test]
184 fn new_event_uid_returns_a_uuid_shaped_string() {
185 let uid = new_event_uid();
187 assert_eq!(uid.len(), 36, "UUID text is 36 chars: {uid:?}");
188 assert_eq!(uid.matches('-').count(), 4, "UUID has 4 hyphens: {uid:?}");
189 }
190
191 #[test]
198 fn session_id_deserialize_rejects_hostile_wire_input() {
199 let empty = serde_json::from_str::<SessionId>(r#""""#);
202 assert!(empty.is_err(), "empty id must be rejected on deserialize");
203 let crlf = serde_json::from_str::<SessionId>(r#""a\r\nfake-log""#);
206 assert!(crlf.is_err(), "CRLF must be rejected on deserialize");
207 let rtl = serde_json::from_str::<SessionId>(r#""\u202Elegit""#);
209 assert!(rtl.is_err(), "non-ASCII must be rejected on deserialize");
210 let too_long = format!(r#""{}""#, "x".repeat(129));
212 assert!(
213 serde_json::from_str::<SessionId>(&too_long).is_err(),
214 "> 128 chars must be rejected on deserialize"
215 );
216 }
217
218 #[test]
219 fn instance_uid_deserialize_rejects_hostile_wire_input() {
220 let empty = serde_json::from_str::<InstanceUid>(r#""""#);
221 assert!(empty.is_err(), "empty instance_uid must be rejected");
222 let non_ascii = serde_json::from_str::<InstanceUid>(r#""agent-é""#);
223 assert!(non_ascii.is_err(), "non-ASCII instance_uid must be rejected");
224 }
225
226 #[test]
229 fn session_id_valid_deserialize_round_trip() {
230 let id = SessionId::parse("sess-abc-123").unwrap();
231 let wire = serde_json::to_string(&id).unwrap();
232 assert_eq!(wire, r#""sess-abc-123""#);
233 let restored: SessionId = serde_json::from_str(&wire).unwrap();
234 assert_eq!(restored, id);
235 }
236}