1use serde::{Deserialize, Serialize};
6#[cfg(any(feature = "nats", feature = "kafka"))]
7use std::collections::HashMap;
8use std::path::{Path, PathBuf};
9
10pub const MANIFEST_VERSION: u32 = 1;
12
13#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(deny_unknown_fields)]
16pub struct RetentionSpec {
17 pub hot_hours: u32,
19 #[serde(default, skip_serializing_if = "Option::is_none")]
21 pub cold_uri: Option<String>,
22}
23
24impl Default for RetentionSpec {
25 fn default() -> Self {
26 Self {
27 hot_hours: 720,
28 cold_uri: None,
29 }
30 }
31}
32
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
35#[serde(deny_unknown_fields)]
36pub struct TopicSpec {
37 pub name: String,
39 pub partitions: u32,
41 #[serde(default)]
43 pub retention: RetentionSpec,
44 #[serde(default, skip_serializing_if = "Option::is_none")]
46 pub schema_ref: Option<String>,
47}
48
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
51#[serde(deny_unknown_fields)]
52pub struct BridgeManifest {
53 pub manifest_version: u32,
55 pub name: String,
57 #[serde(default = "default_replication_factor")]
59 pub replication_factor: u32,
60 pub topics: Vec<TopicSpec>,
62}
63
64fn default_replication_factor() -> u32 {
65 1
66}
67
68#[derive(Debug, thiserror::Error, PartialEq, Eq)]
70#[non_exhaustive]
71pub enum ManifestError {
72 #[error("unsupported manifest_version {0} (this build supports {MANIFEST_VERSION})")]
74 Version(u32),
75 #[error("manifest parse: {0}")]
77 Parse(String),
78 #[error("manifest invalid: {0}")]
80 Invalid(String),
81}
82
83impl BridgeManifest {
84 pub fn default_for(name: &str) -> Self {
86 Self {
87 manifest_version: MANIFEST_VERSION,
88 name: name.to_owned(),
89 replication_factor: default_replication_factor(),
90 topics: av_events::EventClass::all()
91 .iter()
92 .map(|c| TopicSpec {
93 name: c.topic().to_owned(),
94 partitions: 8,
95 retention: RetentionSpec::default(),
96 schema_ref: Some("schemas/ocsf-agent-event.schema.json".to_owned()),
97 })
98 .collect(),
99 }
100 }
101
102 pub fn from_yaml(yaml: &str) -> Result<Self, ManifestError> {
112 const MAX_YAML_BYTES: usize = 256 * 1024;
113 if yaml.len() > MAX_YAML_BYTES {
114 return Err(ManifestError::Parse(format!(
115 "manifest is {} bytes, exceeds cap of {MAX_YAML_BYTES}",
116 yaml.len()
117 )));
118 }
119 for (marker, kind) in [('&', "anchor"), ('*', "alias")] {
129 let mut chars = yaml.char_indices().peekable();
130 while let Some((_, ch)) = chars.next() {
131 if ch != marker {
132 continue;
133 }
134 if let Some(&(_, next)) = chars.peek() {
135 if next.is_ascii_alphanumeric() || next == '_' {
136 return Err(ManifestError::Parse(format!(
137 "manifest contains a YAML {kind} ('{marker}<name>'); \
138 AgentVisor AI refuses anchor/alias syntax to close the \
139 billion-laughs attack surface (serde_yaml expands aliases \
140 with no cap). Rewrite the document without &/* references."
141 )));
142 }
143 }
144 }
145 }
146 let m: Self = serde_yaml::from_str(yaml).map_err(|e| ManifestError::Parse(e.to_string()))?;
147 m.validate()?;
148 Ok(m)
149 }
150
151 pub fn to_yaml(&self) -> Result<String, ManifestError> {
153 serde_yaml::to_string(self).map_err(|e| ManifestError::Parse(e.to_string()))
154 }
155
156 pub fn validate(&self) -> Result<(), ManifestError> {
158 if self.manifest_version != MANIFEST_VERSION {
159 return Err(ManifestError::Version(self.manifest_version));
160 }
161 if self.name.is_empty() {
162 return Err(ManifestError::Invalid("name is empty".into()));
163 }
164 if self.topics.is_empty() {
165 return Err(ManifestError::Invalid("no topics declared".into()));
166 }
167 if !(1..=5).contains(&self.replication_factor) {
168 return Err(ManifestError::Invalid(
169 "replication_factor must be between 1 and 5".to_owned(),
170 ));
171 }
172 let mut names: Vec<&str> = self.topics.iter().map(|t| t.name.as_str()).collect();
173 names.sort_unstable();
174 let before = names.len();
175 names.dedup();
176 if names.len() != before {
177 return Err(ManifestError::Invalid("duplicate topic names".into()));
178 }
179 for t in &self.topics {
180 if t.name.is_empty()
181 || !t
182 .name
183 .bytes()
184 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
185 || t.name == "."
186 || t.name == ".."
187 {
188 return Err(ManifestError::Invalid(format!("unsafe topic name {:?}", t.name)));
189 }
190 if t.partitions == 0 {
191 return Err(ManifestError::Invalid(format!(
192 "topic {:?} has 0 partitions",
193 t.name
194 )));
195 }
196 const MAX_PARTITIONS: u32 = 1024;
204 const MAX_HOT_HOURS: u32 = 24 * 365 * 10;
205 if t.partitions > MAX_PARTITIONS {
206 return Err(ManifestError::Invalid(format!(
207 "topic {:?} partitions {} exceeds cap of {MAX_PARTITIONS}",
208 t.name, t.partitions
209 )));
210 }
211 if t.retention.hot_hours == 0 {
212 return Err(ManifestError::Invalid(format!(
213 "topic {:?} has 0h retention",
214 t.name
215 )));
216 }
217 if t.retention.hot_hours > MAX_HOT_HOURS {
218 return Err(ManifestError::Invalid(format!(
219 "topic {:?} hot_hours {} exceeds cap of {MAX_HOT_HOURS} (10 years)",
220 t.name, t.retention.hot_hours
221 )));
222 }
223 if let Some(reference) = &t.schema_ref {
224 let path = Path::new(reference);
225 if path.is_absolute()
226 || path
227 .components()
228 .any(|component| !matches!(component, std::path::Component::Normal(_)))
229 {
230 return Err(ManifestError::Invalid(format!("unsafe schema_ref {reference:?}")));
231 }
232 }
233 }
234 Ok(())
235 }
236}
237
238#[cfg(any(feature = "nats", feature = "kafka"))]
239pub(crate) fn compile_topic_validators(
240 manifest: &BridgeManifest,
241) -> Result<HashMap<String, jsonschema::Validator>, crate::BusError> {
242 let mut validators = HashMap::new();
243 for topic in &manifest.topics {
244 let Some(reference) = &topic.schema_ref else {
245 continue;
246 };
247 let schema = schema_document(reference)?;
248 let validator = jsonschema::validator_for(&schema)
249 .map_err(|error| crate::BusError::Backend(format!("invalid schema {reference:?}: {error}")))?;
250 validators.insert(topic.name.clone(), validator);
251 }
252 Ok(validators)
253}
254
255#[cfg(any(feature = "nats", feature = "kafka"))]
256pub(crate) fn validate_topic_event(
257 validators: &HashMap<String, jsonschema::Validator>,
258 topic: &str,
259 value: &serde_json::Value,
260) -> Result<(), crate::BusError> {
261 let Some(validator) = validators.get(topic) else {
262 return Ok(());
263 };
264 let errors: Vec<String> = validator
265 .iter_errors(value)
266 .take(3)
267 .map(|error| error.to_string())
268 .collect();
269 if errors.is_empty() {
270 Ok(())
271 } else {
272 Err(crate::BusError::Backend(format!(
273 "event rejected by schema for topic {topic:?}: {}",
274 errors.join("; ")
275 )))
276 }
277}
278
279pub(crate) fn schema_document(reference: &str) -> Result<serde_json::Value, crate::BusError> {
280 if reference == "schemas/ocsf-agent-event.schema.json" {
281 return serde_json::from_str(include_str!("../../../schemas/ocsf-agent-event.schema.json"))
282 .map_err(crate::BusError::from);
283 }
284 let direct = PathBuf::from(reference);
285 if direct.exists() {
286 return serde_json::from_slice(&std::fs::read(direct)?).map_err(crate::BusError::from);
287 }
288 let workspace = Path::new(env!("CARGO_MANIFEST_DIR"))
289 .join("../..")
290 .join(reference);
291 if workspace.exists() {
292 serde_json::from_slice(&std::fs::read(workspace)?).map_err(crate::BusError::from)
293 } else {
294 Err(crate::BusError::Backend(format!(
295 "schema reference {reference:?} could not be resolved"
296 )))
297 }
298}
299
300#[cfg(test)]
301mod tests {
302 #![allow(
303 clippy::unwrap_used,
304 clippy::expect_used,
305 clippy::panic,
306 clippy::indexing_slicing
307 )]
308
309 use super::*;
310
311 #[test]
312 fn default_manifest_covers_all_event_classes() {
313 let m = BridgeManifest::default_for("us-east-lab");
314 m.validate().unwrap();
315 assert_eq!(m.topics.len(), av_events::EventClass::all().len());
316 assert!(m.topics.iter().any(|t| t.name == "agent.tool_call"));
317 assert!(m.topics.iter().any(|t| t.name == "agent.receipt"));
318 assert_eq!(m.topics[0].retention.hot_hours, 720, "default 30 days per brief");
319 }
320
321 #[test]
322 fn yaml_roundtrip() {
323 let m = BridgeManifest::default_for("enclave-1");
324 let yaml = m.to_yaml().unwrap();
325 let back = BridgeManifest::from_yaml(&yaml).unwrap();
326 assert_eq!(m, back);
327 }
328
329 #[test]
330 fn rejects_bad_manifests() {
331 let mut m = BridgeManifest::default_for("x");
332 m.manifest_version = 99;
333 assert_eq!(m.validate(), Err(ManifestError::Version(99)));
334
335 let mut m = BridgeManifest::default_for("x");
336 m.topics[0].partitions = 0;
337 assert!(matches!(m.validate(), Err(ManifestError::Invalid(_))));
338
339 let mut m = BridgeManifest::default_for("x");
340 m.topics[0].name = "../escape".to_owned();
341 assert!(matches!(m.validate(), Err(ManifestError::Invalid(_))));
342
343 let mut m = BridgeManifest::default_for("x");
344 m.topics[0].schema_ref = Some("../outside.json".to_owned());
345 assert!(matches!(m.validate(), Err(ManifestError::Invalid(_))));
346
347 let mut m = BridgeManifest::default_for("x");
348 let dup = m.topics[0].clone();
349 m.topics.push(dup);
350 assert!(matches!(m.validate(), Err(ManifestError::Invalid(_))));
351
352 let m = BridgeManifest {
353 manifest_version: MANIFEST_VERSION,
354 name: String::new(),
355 replication_factor: 1,
356 topics: vec![],
357 };
358 assert!(m.validate().is_err());
359
360 let mut m = BridgeManifest::default_for("x");
365 m.topics[0].partitions = u32::MAX;
366 assert!(matches!(m.validate(), Err(ManifestError::Invalid(_))));
367 let mut m = BridgeManifest::default_for("x");
368 m.topics[0].retention.hot_hours = u32::MAX;
369 assert!(matches!(m.validate(), Err(ManifestError::Invalid(_))));
370 }
371
372 #[test]
379 fn unknown_fields_are_rejected_at_parse_time() {
380 let with_top_level_typo = r"
381manifest_version: 1
382name: probe
383topocs: []
384";
385 assert!(matches!(
386 BridgeManifest::from_yaml(with_top_level_typo),
387 Err(ManifestError::Parse(_))
388 ));
389 let with_topic_typo = r"
390manifest_version: 1
391name: probe
392topics:
393 - name: a
394 partitions: 1
395 schema_reff: something
396";
397 assert!(matches!(
398 BridgeManifest::from_yaml(with_topic_typo),
399 Err(ManifestError::Parse(_))
400 ));
401 let with_retention_typo = r"
402manifest_version: 1
403name: probe
404topics:
405 - name: a
406 partitions: 1
407 retention:
408 hot_horus: 720
409";
410 assert!(matches!(
411 BridgeManifest::from_yaml(with_retention_typo),
412 Err(ManifestError::Parse(_))
413 ));
414 }
415
416 #[test]
417 fn malformed_yaml_is_a_parse_error() {
418 assert!(matches!(
419 BridgeManifest::from_yaml(":\n - not: [valid"),
420 Err(ManifestError::Parse(_))
421 ));
422 }
423
424 #[test]
425 fn default_manifest_matches_shipped_json_schema() {
426 let schema: serde_json::Value =
427 serde_json::from_str(include_str!("../../../schemas/bridge-manifest.schema.json")).unwrap();
428 let validator = jsonschema::validator_for(&schema).unwrap();
429 let value = serde_json::to_value(BridgeManifest::default_for("schema-test")).unwrap();
430 let errors: Vec<_> = validator.iter_errors(&value).collect();
431 assert!(errors.is_empty(), "{errors:?}");
432 }
433}
434
435#[cfg(test)]
436mod mutation_boundary_tests {
437 #![allow(
438 clippy::unwrap_used,
439 clippy::expect_used,
440 clippy::panic,
441 clippy::indexing_slicing
442 )]
443
444 use super::*;
445
446 #[test]
452 fn yaml_anchor_and_alias_markers_are_refused_per_name_class() {
453 let base = BridgeManifest::default_for("anchors").to_yaml().unwrap();
454 for snippet in ["&a1", "&_x", "*a1", "*_x"] {
455 let hostile = format!("{base}# {snippet}\n");
456 assert!(
457 BridgeManifest::from_yaml(&hostile).is_err(),
458 "{snippet} must be refused"
459 );
460 }
461 let benign = format!("{base}# tail & done * \n");
463 BridgeManifest::from_yaml(&benign).unwrap();
464 }
465
466 #[test]
469 fn manifest_size_cap_is_exact() {
470 let base = BridgeManifest::default_for("size-cap").to_yaml().unwrap();
471 let cap = 256 * 1024;
472 let pad_to = |len: usize| {
473 let mut s = base.clone();
474 s.push('#');
475 while s.len() < len {
476 s.push('x');
477 }
478 s
479 };
480 BridgeManifest::from_yaml(&pad_to(cap)).unwrap();
481 let over = BridgeManifest::from_yaml(&pad_to(cap + 1));
482 assert!(
483 matches!(over, Err(ManifestError::Parse(ref m)) if m.contains("exceeds cap")),
484 "one past the cap must refuse, got {over:?}"
485 );
486 }
487
488 #[test]
492 fn topic_name_dots_and_numeric_caps_are_exact() {
493 let mut m = BridgeManifest::default_for("dots");
494 for name in [".", ".."] {
495 m.topics[0].name = name.to_owned();
496 assert!(m.validate().is_err(), "topic name {name:?} must be refused");
497 }
498 m.topics[0].name = "agent.ok".to_owned();
499 m.topics[0].partitions = 1024;
500 m.topics[0].retention.hot_hours = 24 * 365 * 10;
501 m.validate().unwrap();
502 m.topics[0].partitions = 1025;
503 assert!(m.validate().is_err(), "1025 partitions past cap");
504 m.topics[0].partitions = 1;
505 m.topics[0].retention.hot_hours = 24 * 365 * 10 + 1;
506 assert!(m.validate().is_err(), "hot_hours past the 10y cap");
507 }
508
509 #[test]
514 #[cfg(any(feature = "nats", feature = "kafka"))]
515 fn compiled_topic_validators_reject_nonconforming_events() {
516 let mut m = BridgeManifest::default_for("schemas");
517 let dir = tempfile::tempdir().unwrap();
519 let schema_path = dir.path().join("strict.json");
520 std::fs::write(
521 &schema_path,
522 br#"{"type":"object","required":["metadata"],"properties":{"metadata":{"type":"object"}}}"#,
523 )
524 .unwrap();
525 for t in &mut m.topics {
526 t.schema_ref = None;
527 }
528 m.topics[0].schema_ref = Some(schema_path.to_string_lossy().into_owned());
529 let topic = m.topics[0].name.clone();
530 let validators = compile_topic_validators(&m).unwrap();
531 validate_topic_event(&validators, &topic, &serde_json::json!({"metadata": {}})).unwrap();
532 let refused = validate_topic_event(&validators, &topic, &serde_json::json!({"not": "conforming"}));
533 assert!(refused.is_err(), "nonconforming event must be refused");
534 }
535}