Skip to main content

av_core/
ids.rs

1//! Identifier newtypes. UUIDv7 gives time-ordered ids (useful for log locality)
2//! while remaining globally unique.
3
4use serde::{Deserialize, Serialize};
5use std::fmt;
6
7/// A session identifier: UUIDv7 canonical text when generated here
8/// ([`new_session_id`]); externally supplied ids may be any non-empty
9/// visible-ASCII string ≤ 128 bytes (header-safe).
10///
11/// Serialization is transparent (the wire form is a plain string), but
12/// deserialization runs [`Self::parse`] so wire-supplied ids can never
13/// bypass the visible-ASCII / length invariants that downstream code
14/// (loggers, header emitters, filesystem-path composers) relies on.
15/// A `#[serde(transparent)]` derive would forward to `String`'s impl
16/// and silently accept `""`, `"\n\r"`, Trojan-Source unicode, or
17/// megabyte-long ids embedded in any struct field that carries a
18/// SessionId.
19#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
20#[serde(transparent)]
21pub struct SessionId(String);
22
23impl SessionId {
24    /// Wrap an externally supplied session id (validated non-empty, ≤ 128 chars,
25    /// visible ASCII only — header-safe).
26    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    /// Access the string form.
34    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/// An agent instance identifier (`ai_agent.instance_uid`).
54///
55/// Same invariants as [`SessionId`]; the custom `Deserialize` runs
56/// [`Self::parse`] so wire-supplied ids embedded in any struct field
57/// cannot bypass the visible-ASCII / length checks.
58#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
59#[serde(transparent)]
60pub struct InstanceUid(String);
61
62impl InstanceUid {
63    /// Wrap an externally supplied instance uid with the same constraints as
64    /// [`SessionId::parse`].
65    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    /// Access the string form.
73    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
92/// Generate a fresh session id (UUIDv7).
93pub fn new_session_id() -> SessionId {
94    SessionId(uuid::Uuid::now_v7().to_string())
95}
96
97/// Generate a fresh event uid (UUIDv7).
98pub 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    /// Every byte that could be used to escape a log line, terminal control
125    /// sequence, or cross-line boundary must be rejected. This locks the
126    /// defense: any `SessionId` that survives `parse` is safe to interpolate
127    /// into log lines and event chains, so hostile `X-AV-Session` values can
128    /// never ride a *parsed* id into a forged log record. (Middleware that
129    /// logs the raw header string separately relies on the tracing layer's
130    /// own field escaping.)
131    #[test]
132    fn session_id_rejects_every_log_injection_byte() {
133        // Cover: NUL, tab, LF, CR, ESC, DEL, and every high-ASCII byte.
134        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            // Also test with the byte as the leading character.
144            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        // Catches `> 128` vs `== 128` / `>= 128` mutations on both types.
162        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        // Catches `as_str -> "xyzzy"` / `-> ""` and Display default-return.
175        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        // Catches `new_event_uid -> String::new()` and `-> "xyzzy".into()`.
186        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    /// Deserializing a `SessionId` MUST run the same visible-ASCII /
192    /// length invariants as `parse` — otherwise any struct with a
193    /// `SessionId` field silently accepts an empty id, a Trojan-Source
194    /// unicode payload, or a megabyte-long string, defeating every
195    /// downstream invariant (log injection, header emission,
196    /// filesystem-path composition) that trusts `parse` succeeded.
197    #[test]
198    fn session_id_deserialize_rejects_hostile_wire_input() {
199        // Empty string — bypasses the `is_empty()` guard if we forwarded
200        // to `String::deserialize`.
201        let empty = serde_json::from_str::<SessionId>(r#""""#);
202        assert!(empty.is_err(), "empty id must be rejected on deserialize");
203        // CRLF injection — every log line embedding a raw id would be
204        // trivially spoofable.
205        let crlf = serde_json::from_str::<SessionId>(r#""a\r\nfake-log""#);
206        assert!(crlf.is_err(), "CRLF must be rejected on deserialize");
207        // Unicode Trojan Source (right-to-left override).
208        let rtl = serde_json::from_str::<SessionId>(r#""\u202Elegit""#);
209        assert!(rtl.is_err(), "non-ASCII must be rejected on deserialize");
210        // 129 chars — one over the boundary.
211        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    /// Serialize is transparent: a `SessionId` round-trips as a plain
227    /// string, and a *valid* id survives the round-trip unchanged.
228    #[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}