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() {
28            return Err(crate::CoreError::InvalidId("session id is empty".to_owned()));
29        }
30        if s.len() > 128 {
31            // NEVER echo the full oversized value: an attacker who sends
32            // a 100 KB `X-AV-Session` header would otherwise get the
33            // entire hostile bytes back in the 400 response body (and
34            // in every log line that carries this error) — free ~2×
35            // amplification and log-storage pollution per malformed
36            // request. The length alone is diagnostic; a short
37            // fingerprint at the head/tail helps a developer notice
38            // trailing whitespace or accidental encoding.
39            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            // Length is bounded (≤ 128 by the check above), so echoing
47            // the value here is safe — and the byte class is what an
48            // operator needs to see to fix a broken client.
49            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    /// Access the string form.
57    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/// An agent instance identifier (`ai_agent.instance_uid`).
77///
78/// Same invariants as [`SessionId`]; the custom `Deserialize` runs
79/// [`Self::parse`] so wire-supplied ids embedded in any struct field
80/// cannot bypass the visible-ASCII / length checks.
81#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
82#[serde(transparent)]
83pub struct InstanceUid(String);
84
85impl InstanceUid {
86    /// Wrap an externally supplied instance uid with the same constraints as
87    /// [`SessionId::parse`].
88    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            // Same amplification defense as SessionId::parse.
94            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    /// Access the string form.
109    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
128/// Generate a fresh session id (UUIDv7).
129pub fn new_session_id() -> SessionId {
130    SessionId(uuid::Uuid::now_v7().to_string())
131}
132
133/// Generate a fresh event uid (UUIDv7).
134pub 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        // R55 mutation-run hardening: the `uuid` crate uses a shared
147        // monotonic counter within a single millisecond, so `a < b`
148        // (strict) is the actual invariant. `a <= b` (non-strict)
149        // would tolerate a mutation replacing `Uuid::now_v7()` with
150        // `Uuid::new_v4()` about 50 % of the time (v4 has no time
151        // ordering, so half the paired comparisons would still
152        // satisfy `<=`). Batch-generate so a mutation would be
153        // caught deterministically.
154        const N: usize = 64;
155        let mut ids: Vec<_> = (0..N).map(|_| new_event_uid()).collect();
156        // Slice-pattern destructuring instead of pair[0]/pair[1]:
157        // clippy::indexing_slicing is denied workspace-wide and fires
158        // in tests too (the module allow-list covers panics, not
159        // indexing). windows(2) guarantees the shape.
160        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        // Also verify the batch itself is already sorted (no external
166        // sort needed) — the shared-monotonic property under the
167        // `uuid` crate is that successive calls are strictly monotone.
168        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    /// Amplification defense: an over-length id must NOT be echoed in
186    /// full in the validation error. Before this fix, an attacker's
187    /// 10 KiB X-AV-Session header came back inside the 400 response
188    /// body (~2× amplification) and inside every log line that carried
189    /// the error — a free per-request DoS multiplier and a
190    /// log-storage-pollution vector.
191    #[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        // Same defense for InstanceUid.
205        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    /// Multi-byte UTF-8 at the truncation boundary must not panic. The
210    /// preview uses `floor_char_boundary` explicitly for this — a naive
211    /// `&s[..24]` would split a 3-byte codepoint straddling the 24th
212    /// byte and panic.
213    #[test]
214    fn oversize_session_id_error_survives_utf8_at_the_truncation_boundary() {
215        // 26 leading bytes of ASCII, then a 3-byte codepoint straddling
216        // byte 24 (index 22 + 3 bytes = 22..25). The preview truncates
217        // at 24 → must NOT split the codepoint.
218        let mut input = "a".repeat(22);
219        input.push('€'); // 3 bytes: E2 82 AC
220        input.push_str(&"b".repeat(200));
221        // First byte outside visible-ASCII → hits the ascii branch, so
222        // force the length branch by making it > 128 chars first.
223        // (The bytes-check runs only after the length check.)
224        let err = SessionId::parse(&input).unwrap_err().to_string();
225        assert!(err.len() < 128, "{err}");
226    }
227
228    /// Every byte that could be used to escape a log line, terminal control
229    /// sequence, or cross-line boundary must be rejected. This locks the
230    /// defense: any `SessionId` that survives `parse` is safe to interpolate
231    /// into log lines and event chains, so hostile `X-AV-Session` values can
232    /// never ride a *parsed* id into a forged log record. (Middleware that
233    /// logs the raw header string separately relies on the tracing layer's
234    /// own field escaping.)
235    #[test]
236    fn session_id_rejects_every_log_injection_byte() {
237        // Cover: NUL, tab, LF, CR, ESC, DEL, and every high-ASCII byte.
238        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            // Also test with the byte as the leading character.
248            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        // Catches `> 128` vs `== 128` / `>= 128` mutations on both types.
266        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        // Catches `as_str -> "xyzzy"` / `-> ""` and Display default-return.
279        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        // Catches `new_event_uid -> String::new()` and `-> "xyzzy".into()`.
290        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    /// Deserializing a `SessionId` MUST run the same visible-ASCII /
296    /// length invariants as `parse` — otherwise any struct with a
297    /// `SessionId` field silently accepts an empty id, a Trojan-Source
298    /// unicode payload, or a megabyte-long string, defeating every
299    /// downstream invariant (log injection, header emission,
300    /// filesystem-path composition) that trusts `parse` succeeded.
301    #[test]
302    fn session_id_deserialize_rejects_hostile_wire_input() {
303        // Empty string — bypasses the `is_empty()` guard if we forwarded
304        // to `String::deserialize`.
305        let empty = serde_json::from_str::<SessionId>(r#""""#);
306        assert!(empty.is_err(), "empty id must be rejected on deserialize");
307        // CRLF injection — every log line embedding a raw id would be
308        // trivially spoofable.
309        let crlf = serde_json::from_str::<SessionId>(r#""a\r\nfake-log""#);
310        assert!(crlf.is_err(), "CRLF must be rejected on deserialize");
311        // Unicode Trojan Source (right-to-left override).
312        let rtl = serde_json::from_str::<SessionId>(r#""\u202Elegit""#);
313        assert!(rtl.is_err(), "non-ASCII must be rejected on deserialize");
314        // 129 chars — one over the boundary.
315        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    /// Serialize is transparent: a `SessionId` round-trips as a plain
331    /// string, and a *valid* id survives the round-trip unchanged.
332    #[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}