1use crate::model::OcsfEvent;
5
6#[derive(Debug, thiserror::Error, PartialEq, Eq)]
8#[non_exhaustive]
9pub enum ValidationError {
10 #[error("required field `{0}` is empty")]
12 EmptyField(&'static str),
13 #[error("type_uid {type_uid} != class_uid {class_uid} * 100 + activity_id {activity_id}")]
15 TypeUidMismatch {
16 type_uid: u64,
18 class_uid: u32,
20 activity_id: u8,
22 },
23 #[error("class_uid {0} does not match class_name")]
25 ClassUidMismatch(u32),
26 #[error("category_uid {0} is not Application Activity (6)")]
28 BadCategory(u8),
29 #[error("ai_agent.charter.type_id {0} is not Regular File (1)")]
31 BadCharterType(u8),
32 #[error("severity_id {0} outside 1..=6")]
34 BadSeverity(u8),
35 #[error("status_id {0} outside 0..=2")]
37 BadStatus(u8),
38 #[error("activity_id {0} outside 0..=99 (would collide type_uid namespaces)")]
48 BadActivityId(u8),
49 #[error("stop_reason and stop_reason_id must be present together")]
51 StopReasonPairMismatch,
52 #[error("time is zero")]
54 ZeroTime,
55}
56
57pub fn validate_event(ev: &OcsfEvent) -> Result<(), Vec<ValidationError>> {
60 let mut errors = Vec::new();
61 if ev.metadata.uid.is_empty() {
62 errors.push(ValidationError::EmptyField("metadata.uid"));
63 }
64 if ev.metadata.version.is_empty() {
65 errors.push(ValidationError::EmptyField("metadata.version"));
66 }
67 if ev.metadata.product.name.is_empty() {
68 errors.push(ValidationError::EmptyField("metadata.product.name"));
69 }
70 if ev.metadata.product.vendor_name.is_empty() {
71 errors.push(ValidationError::EmptyField("metadata.product.vendor_name"));
72 }
73 if ev.metadata.product.version.is_empty() {
74 errors.push(ValidationError::EmptyField("metadata.product.version"));
75 }
76 if ev.session_uid.is_empty() {
77 errors.push(ValidationError::EmptyField("session_uid"));
78 }
79 if ev.ai_agent.version.is_empty() {
80 errors.push(ValidationError::EmptyField("ai_agent.version"));
81 }
82 if ev.ai_agent.charter.name.is_empty() {
83 errors.push(ValidationError::EmptyField("ai_agent.charter.name"));
84 }
85 if ev.ai_agent.charter.type_id != 1 {
86 errors.push(ValidationError::BadCharterType(ev.ai_agent.charter.type_id));
87 }
88 if ev.ai_agent.instance_uid.is_empty() {
89 errors.push(ValidationError::EmptyField("ai_agent.instance_uid"));
90 }
91 if ev.class_uid != ev.class_name.class_uid() {
92 errors.push(ValidationError::ClassUidMismatch(ev.class_uid));
93 }
94 if ev.category_uid != crate::model::CATEGORY_UID {
95 errors.push(ValidationError::BadCategory(ev.category_uid));
96 }
97 let expected_type = u64::from(ev.class_name.class_uid()) * 100 + u64::from(ev.activity_id);
98 if ev.type_uid != expected_type {
99 errors.push(ValidationError::TypeUidMismatch {
100 type_uid: ev.type_uid,
101 class_uid: ev.class_uid,
102 activity_id: ev.activity_id,
103 });
104 }
105 if ev.activity_id >= 100 {
113 errors.push(ValidationError::BadActivityId(ev.activity_id));
114 }
115 if !(1..=6).contains(&ev.severity_id) {
116 errors.push(ValidationError::BadSeverity(ev.severity_id));
117 }
118 if ev.status_id > 2 {
119 errors.push(ValidationError::BadStatus(ev.status_id));
120 }
121 if ev.stop_reason_id.is_some() != ev.stop_reason.is_some() {
122 errors.push(ValidationError::StopReasonPairMismatch);
123 }
124 if ev.time == 0 {
125 errors.push(ValidationError::ZeroTime);
126 }
127 if errors.is_empty() {
128 Ok(())
129 } else {
130 Err(errors)
131 }
132}
133
134#[cfg(test)]
135mod tests {
136 #![allow(
137 clippy::unwrap_used,
138 clippy::expect_used,
139 clippy::panic,
140 clippy::indexing_slicing
141 )]
142
143 use super::*;
144 use crate::model::{AgentIdentity, EventClass, OcsfEventBuilder};
145 use crate::StopReason;
146
147 fn valid_event() -> OcsfEvent {
148 OcsfEventBuilder::new(
149 EventClass::ToolCall,
150 "sess",
151 AgentIdentity {
152 version: "1".into(),
153 charter: "c".into(),
154 instance_uid: "i".into(),
155 ttl_remaining_s: None,
156 },
157 1,
158 )
159 .stop_reason(StopReason::PolicyBlocked)
160 .build()
161 .unwrap()
162 }
163
164 #[test]
165 fn valid_event_passes() {
166 assert!(validate_event(&valid_event()).is_ok());
167 }
168
169 #[test]
170 fn empty_identity_fields_all_reported() {
171 let mut ev = valid_event();
172 ev.ai_agent.version.clear();
173 ev.ai_agent.charter.name.clear();
174 ev.ai_agent.instance_uid.clear();
175 let errs = validate_event(&ev).unwrap_err();
176 assert_eq!(errs.len(), 3, "collect-all: {errs:?}");
177 }
178
179 #[test]
180 fn tampered_type_uid_detected() {
181 let mut ev = valid_event();
182 ev.type_uid += 1;
183 assert!(matches!(
184 validate_event(&ev).unwrap_err().first(),
185 Some(ValidationError::TypeUidMismatch { .. })
186 ));
187 }
188
189 #[test]
190 fn orphan_stop_reason_caption_detected() {
191 let mut ev = valid_event();
192 ev.stop_reason_id = None; assert!(validate_event(&ev)
194 .unwrap_err()
195 .contains(&ValidationError::StopReasonPairMismatch));
196 }
197
198 #[test]
199 fn bad_severity_detected() {
200 let mut ev = valid_event();
201 ev.severity_id = 0;
202 assert!(validate_event(&ev)
203 .unwrap_err()
204 .contains(&ValidationError::BadSeverity(0)));
205 ev.severity_id = 7;
206 assert!(validate_event(&ev)
207 .unwrap_err()
208 .contains(&ValidationError::BadSeverity(7)));
209 }
210
211 #[test]
212 fn status_id_boundary_accepts_up_to_2_rejects_3() {
213 let mut ev = valid_event();
216 for valid in [0u8, 1, 2] {
217 ev.status_id = valid;
218 let errs = validate_event(&ev).map(|_| Vec::new()).unwrap_or_else(|e| e);
219 assert!(
220 !errs.iter().any(|e| matches!(e, ValidationError::BadStatus(_))),
221 "status_id={valid} incorrectly flagged: {errs:?}"
222 );
223 }
224 ev.status_id = 3;
225 assert!(validate_event(&ev)
226 .unwrap_err()
227 .contains(&ValidationError::BadStatus(3)));
228 }
229
230 #[test]
238 fn activity_id_100_is_rejected_even_when_type_uid_matches() {
239 let mut ev = valid_event();
240 ev.activity_id = 100;
241 ev.type_uid = u64::from(ev.class_name.class_uid()) * 100 + 100;
244 let errs = validate_event(&ev).unwrap_err();
245 assert!(
246 errs.contains(&ValidationError::BadActivityId(100)),
247 "activity_id=100 must be flagged even when type_uid matches; got {errs:?}"
248 );
249 ev.activity_id = 99;
251 ev.type_uid = u64::from(ev.class_name.class_uid()) * 100 + 99;
252 let errs = validate_event(&ev).map(|_| Vec::new()).unwrap_or_else(|e| e);
253 assert!(
254 !errs
255 .iter()
256 .any(|e| matches!(e, ValidationError::BadActivityId(_))),
257 "activity_id=99 must be valid; got {errs:?}"
258 );
259 }
260}