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 pub retention: RetentionSpec,
51 #[serde(default, skip_serializing_if = "Option::is_none")]
53 pub schema_ref: Option<String>,
54}
55
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
58#[serde(deny_unknown_fields)]
59pub struct BridgeManifest {
60 pub manifest_version: u32,
62 pub name: String,
64 #[serde(default = "default_replication_factor")]
66 pub replication_factor: u32,
67 pub topics: Vec<TopicSpec>,
69}
70
71fn default_replication_factor() -> u32 {
72 1
73}
74
75#[derive(Debug, thiserror::Error, PartialEq, Eq)]
77#[non_exhaustive]
78pub enum ManifestError {
79 #[error("unsupported manifest_version {0} (this build supports {MANIFEST_VERSION})")]
81 Version(u32),
82 #[error("manifest parse: {0}")]
84 Parse(String),
85 #[error("manifest invalid: {0}")]
87 Invalid(String),
88}
89
90impl BridgeManifest {
91 pub fn default_for(name: &str) -> Self {
93 Self {
94 manifest_version: MANIFEST_VERSION,
95 name: name.to_owned(),
96 replication_factor: default_replication_factor(),
97 topics: av_events::EventClass::all()
98 .iter()
99 .map(|c| TopicSpec {
100 name: c.topic().to_owned(),
101 partitions: 8,
102 retention: RetentionSpec::default(),
103 schema_ref: Some("schemas/ocsf-agent-event.schema.json".to_owned()),
104 })
105 .collect(),
106 }
107 }
108
109 pub fn from_yaml(yaml: &str) -> Result<Self, ManifestError> {
119 const MAX_YAML_BYTES: usize = 256 * 1024;
120 if yaml.len() > MAX_YAML_BYTES {
121 return Err(ManifestError::Parse(format!(
122 "manifest is {} bytes, exceeds cap of {MAX_YAML_BYTES}",
123 yaml.len()
124 )));
125 }
126 for (marker, kind) in [('&', "anchor"), ('*', "alias")] {
141 let mut chars = yaml.char_indices().peekable();
142 while let Some((_, ch)) = chars.next() {
143 if ch != marker {
144 continue;
145 }
146 if let Some(&(_, next)) = chars.peek() {
147 if next.is_ascii_alphanumeric() || next == '_' || next == '-' {
148 return Err(ManifestError::Parse(format!(
149 "manifest contains a YAML {kind} ('{marker}<name>'); \
150 AgentVisor AI refuses anchor/alias syntax to close the \
151 billion-laughs attack surface (serde_yaml expands aliases \
152 with no cap). Rewrite the document without &/* references."
153 )));
154 }
155 }
156 }
157 }
158 let m: Self = serde_yaml::from_str(yaml).map_err(|e| ManifestError::Parse(e.to_string()))?;
159 m.validate()?;
160 Ok(m)
161 }
162
163 pub fn to_yaml(&self) -> Result<String, ManifestError> {
165 serde_yaml::to_string(self).map_err(|e| ManifestError::Parse(e.to_string()))
166 }
167
168 pub fn validate(&self) -> Result<(), ManifestError> {
170 if self.manifest_version != MANIFEST_VERSION {
171 return Err(ManifestError::Version(self.manifest_version));
172 }
173 if self.name.is_empty() {
174 return Err(ManifestError::Invalid("name is empty".into()));
175 }
176 refuse_yaml_marker_chars("name", &self.name)?;
185 if self.topics.is_empty() {
186 return Err(ManifestError::Invalid("no topics declared".into()));
187 }
188 if self.topics.len() > 256 {
194 return Err(ManifestError::Invalid(format!(
195 "{} topics declared, above the schema maximum of 256",
196 self.topics.len()
197 )));
198 }
199 if !(1..=5).contains(&self.replication_factor) {
200 return Err(ManifestError::Invalid(
201 "replication_factor must be between 1 and 5".to_owned(),
202 ));
203 }
204 let mut names: Vec<&str> = self.topics.iter().map(|t| t.name.as_str()).collect();
205 names.sort_unstable();
206 let before = names.len();
207 names.dedup();
208 if names.len() != before {
209 return Err(ManifestError::Invalid("duplicate topic names".into()));
210 }
211 let mut folded: Vec<String> = self.topics.iter().map(|t| t.name.to_ascii_lowercase()).collect();
220 folded.sort_unstable();
221 let before = folded.len();
222 folded.dedup();
223 if folded.len() != before {
224 return Err(ManifestError::Invalid(
225 "topic names collide case-insensitively (unsafe on case-insensitive filesystems)".into(),
226 ));
227 }
228 let mut stream_folded: Vec<String> = self
236 .topics
237 .iter()
238 .map(|t| t.name.to_ascii_lowercase().replace('.', "_"))
239 .collect();
240 stream_folded.sort_unstable();
241 let before = stream_folded.len();
242 stream_folded.dedup();
243 if stream_folded.len() != before {
244 return Err(ManifestError::Invalid(
245 "topic names collide after '.'→'_' folding (ambiguous NATS stream names)".into(),
246 ));
247 }
248 for t in &self.topics {
249 if t.name.is_empty()
250 || !t
251 .name
252 .bytes()
253 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
254 || t.name == "."
255 || t.name == ".."
256 {
257 return Err(ManifestError::Invalid(format!("unsafe topic name {:?}", t.name)));
258 }
259 if t.name.len() > 249 {
268 return Err(ManifestError::Invalid(format!(
269 "topic name {:?}... is {} bytes, above the 249-byte limit",
270 t.name.get(..24).unwrap_or(&t.name),
271 t.name.len()
272 )));
273 }
274 if t.partitions == 0 {
275 return Err(ManifestError::Invalid(format!(
276 "topic {:?} has 0 partitions",
277 t.name
278 )));
279 }
280 const MAX_PARTITIONS: u32 = 1024;
288 const MAX_HOT_HOURS: u32 = 24 * 365 * 10;
289 const MAX_REFERENCE_BYTES: usize = 4096;
292 if t.partitions > MAX_PARTITIONS {
293 return Err(ManifestError::Invalid(format!(
294 "topic {:?} partitions {} exceeds cap of {MAX_PARTITIONS}",
295 t.name, t.partitions
296 )));
297 }
298 if t.retention.hot_hours == 0 {
299 return Err(ManifestError::Invalid(format!(
300 "topic {:?} has 0h retention",
301 t.name
302 )));
303 }
304 if t.retention.hot_hours > MAX_HOT_HOURS {
305 return Err(ManifestError::Invalid(format!(
306 "topic {:?} hot_hours {} exceeds cap of {MAX_HOT_HOURS} (10 years)",
307 t.name, t.retention.hot_hours
308 )));
309 }
310 if let Some(reference) = &t.schema_ref {
311 refuse_yaml_marker_chars("schema_ref", reference)?;
316 if reference.is_empty() {
322 return Err(ManifestError::Invalid(format!(
323 "topic {:?} schema_ref is empty (schema requires minLength 1); omit the field instead",
324 t.name
325 )));
326 }
327 if reference.len() > MAX_REFERENCE_BYTES {
328 return Err(ManifestError::Invalid(format!(
329 "schema_ref exceeds the schema maxLength of {MAX_REFERENCE_BYTES} bytes ({})",
330 reference.len()
331 )));
332 }
333 let path = Path::new(reference);
334 if path.is_absolute()
335 || path
336 .components()
337 .any(|component| !matches!(component, std::path::Component::Normal(_)))
338 {
339 return Err(ManifestError::Invalid(format!("unsafe schema_ref {reference:?}")));
340 }
341 }
342 if let Some(uri) = &t.retention.cold_uri {
343 refuse_yaml_marker_chars("cold_uri", uri)?;
344 if uri.is_empty() {
351 return Err(ManifestError::Invalid(format!(
352 "topic {:?} cold_uri is empty (schema requires minLength 1); omit the field to disable cold export",
353 t.name
354 )));
355 }
356 if uri.len() > MAX_REFERENCE_BYTES {
357 return Err(ManifestError::Invalid(format!(
358 "cold_uri exceeds the schema maxLength of {MAX_REFERENCE_BYTES} bytes ({})",
359 uri.len()
360 )));
361 }
362 if !uri.contains("://") {
374 let path = Path::new(uri);
375 if path
376 .components()
377 .any(|component| matches!(component, std::path::Component::ParentDir))
378 {
379 return Err(ManifestError::Invalid(format!(
380 "unsafe cold_uri {uri:?} (must not contain `..` components; \
381 use a scheme URI or a concrete absolute/relative path with \
382 no parent-directory traversal)"
383 )));
384 }
385 }
386 }
387 }
388 Ok(())
389 }
390}
391
392fn refuse_yaml_marker_chars(field: &str, value: &str) -> Result<(), ManifestError> {
398 if value.contains('&') || value.contains('*') {
399 return Err(ManifestError::Invalid(format!(
400 "{field} {value:?} contains '&' or '*', which the YAML anchor/alias \
401 guard refuses at parse time; remove those characters so the \
402 provisioned manifest can be re-opened"
403 )));
404 }
405 Ok(())
406}
407
408#[cfg(any(feature = "nats", feature = "kafka"))]
409pub(crate) fn compile_topic_validators(
410 manifest: &BridgeManifest,
411) -> Result<HashMap<String, jsonschema::Validator>, crate::BusError> {
412 let mut validators = HashMap::new();
413 for topic in &manifest.topics {
414 let Some(reference) = &topic.schema_ref else {
415 continue;
416 };
417 let schema = schema_document(reference)?;
418 let validator = jsonschema::options()
419 .should_validate_formats(true)
420 .build(&schema)
421 .map_err(|error| crate::BusError::Backend(format!("invalid schema {reference:?}: {error}")))?;
422 validators.insert(topic.name.clone(), validator);
423 }
424 Ok(validators)
425}
426
427#[cfg(any(feature = "nats", feature = "kafka"))]
428pub(crate) fn validate_topic_event(
429 validators: &HashMap<String, jsonschema::Validator>,
430 topic: &str,
431 value: &serde_json::Value,
432) -> Result<(), crate::BusError> {
433 let Some(validator) = validators.get(topic) else {
434 return Ok(());
435 };
436 let errors: Vec<String> = validator
437 .iter_errors(value)
438 .take(3)
439 .map(|error| error.to_string())
440 .collect();
441 if errors.is_empty() {
442 Ok(())
443 } else {
444 Err(crate::BusError::Backend(format!(
445 "event rejected by schema for topic {topic:?}: {}",
446 errors.join("; ")
447 )))
448 }
449}
450
451pub(crate) fn schema_document(reference: &str) -> Result<serde_json::Value, crate::BusError> {
452 if reference == "schemas/ocsf-agent-event.schema.json" {
453 return serde_json::from_str(include_str!("../schemas/ocsf-agent-event.schema.json"))
459 .map_err(crate::BusError::from);
460 }
461 let direct = PathBuf::from(reference);
462 if direct.exists() {
463 let bytes = av_core::fsutil::read_capped(&direct, av_core::fsutil::MAX_CONTROL_BYTES)?;
467 return serde_json::from_slice(&bytes).map_err(crate::BusError::from);
468 }
469 let workspace = Path::new(env!("CARGO_MANIFEST_DIR"))
470 .join("../..")
471 .join(reference);
472 if workspace.exists() {
473 let bytes = av_core::fsutil::read_capped(&workspace, av_core::fsutil::MAX_CONTROL_BYTES)?;
474 serde_json::from_slice(&bytes).map_err(crate::BusError::from)
475 } else {
476 Err(crate::BusError::Backend(format!(
477 "schema reference {reference:?} could not be resolved"
478 )))
479 }
480}
481
482#[cfg(test)]
483mod tests {
484 #![allow(
485 clippy::unwrap_used,
486 clippy::expect_used,
487 clippy::panic,
488 clippy::indexing_slicing
489 )]
490
491 use super::*;
492
493 #[test]
499 fn embedded_schema_matches_workspace_copy() {
500 let workspace = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
501 .join("../../schemas/ocsf-agent-event.schema.json");
502 let Ok(canonical) = std::fs::read_to_string(&workspace) else {
503 return;
505 };
506 assert_eq!(
507 include_str!("../schemas/ocsf-agent-event.schema.json"),
508 canonical,
509 "crates/av-bridge/schemas/ocsf-agent-event.schema.json has drifted from \
510 schemas/ocsf-agent-event.schema.json — copy the canonical file over the vendored one"
511 );
512 }
513
514 #[test]
515 fn default_manifest_covers_all_event_classes() {
516 let m = BridgeManifest::default_for("us-east-lab");
517 m.validate().unwrap();
518 assert_eq!(m.topics.len(), av_events::EventClass::all().len());
519 assert!(m.topics.iter().any(|t| t.name == "agent.tool_call"));
520 assert!(m.topics.iter().any(|t| t.name == "agent.receipt"));
521 assert_eq!(m.topics[0].retention.hot_hours, 720, "default 30 days per brief");
522 }
523
524 #[test]
528 fn topic_count_and_name_length_boundaries() {
529 let mut m = BridgeManifest::default_for("caps");
530 let template = m.topics[0].clone();
531 while m.topics.len() < 256 {
532 let mut extra = template.clone();
533 extra.name = format!("agent.extra_{}", m.topics.len());
534 m.topics.push(extra);
535 }
536 m.validate().expect("256 topics is the inclusive max");
537 let mut over = m.clone();
538 let mut extra = template.clone();
539 extra.name = "agent.extra_overflow".to_owned();
540 over.topics.push(extra);
541 assert!(over.validate().is_err(), "257 topics must be refused");
542
543 let mut m = BridgeManifest::default_for("names");
544 m.topics[0].name = format!("agent.{}", "a".repeat(243)); m.validate().expect("249-byte topic name is the inclusive max");
546 m.topics[0].name = format!("agent.{}", "a".repeat(244)); assert!(m.validate().is_err(), "250-byte topic name must be refused");
548 }
549
550 #[test]
551 fn yaml_roundtrip() {
552 let m = BridgeManifest::default_for("enclave-1");
553 let yaml = m.to_yaml().unwrap();
554 let back = BridgeManifest::from_yaml(&yaml).unwrap();
555 assert_eq!(m, back);
556 }
557
558 #[test]
559 fn rejects_bad_manifests() {
560 let mut m = BridgeManifest::default_for("x");
561 m.manifest_version = 99;
562 assert_eq!(m.validate(), Err(ManifestError::Version(99)));
563
564 let mut m = BridgeManifest::default_for("x");
565 m.topics[0].partitions = 0;
566 assert!(matches!(m.validate(), Err(ManifestError::Invalid(_))));
567
568 let mut m = BridgeManifest::default_for("x");
569 m.topics[0].name = "../escape".to_owned();
570 assert!(matches!(m.validate(), Err(ManifestError::Invalid(_))));
571
572 let mut m = BridgeManifest::default_for("x");
573 m.topics[0].schema_ref = Some("../outside.json".to_owned());
574 assert!(matches!(m.validate(), Err(ManifestError::Invalid(_))));
575
576 let mut m = BridgeManifest::default_for("x");
579 m.topics[0].schema_ref = Some(format!("schemas/{}.json", "a".repeat(4096 - 13)));
580 assert!(m.validate().is_ok(), "4096-byte schema_ref refused");
581 let mut m = BridgeManifest::default_for("x");
582 m.topics[0].schema_ref = Some(format!("schemas/{}.json", "a".repeat(4097 - 13)));
583 assert!(matches!(m.validate(), Err(ManifestError::Invalid(_))));
584 let mut m = BridgeManifest::default_for("x");
585 m.topics[0].retention.cold_uri = Some(format!("/cold/{}", "a".repeat(4091)));
586 assert!(matches!(m.validate(), Err(ManifestError::Invalid(_))));
587
588 let mut m = BridgeManifest::default_for("x");
589 let dup = m.topics[0].clone();
590 m.topics.push(dup);
591 assert!(matches!(m.validate(), Err(ManifestError::Invalid(_))));
592
593 let m = BridgeManifest {
594 manifest_version: MANIFEST_VERSION,
595 name: String::new(),
596 replication_factor: 1,
597 topics: vec![],
598 };
599 assert!(m.validate().is_err());
600
601 let mut m = BridgeManifest::default_for("x");
606 m.topics[0].partitions = u32::MAX;
607 assert!(matches!(m.validate(), Err(ManifestError::Invalid(_))));
608 let mut m = BridgeManifest::default_for("x");
609 m.topics[0].retention.hot_hours = u32::MAX;
610 assert!(matches!(m.validate(), Err(ManifestError::Invalid(_))));
611 }
612
613 #[test]
620 fn unknown_fields_are_rejected_at_parse_time() {
621 let with_top_level_typo = r"
622manifest_version: 1
623name: probe
624topocs: []
625";
626 assert!(matches!(
627 BridgeManifest::from_yaml(with_top_level_typo),
628 Err(ManifestError::Parse(_))
629 ));
630 let with_topic_typo = r"
631manifest_version: 1
632name: probe
633topics:
634 - name: a
635 partitions: 1
636 schema_reff: something
637";
638 assert!(matches!(
639 BridgeManifest::from_yaml(with_topic_typo),
640 Err(ManifestError::Parse(_))
641 ));
642 let with_retention_typo = r"
643manifest_version: 1
644name: probe
645topics:
646 - name: a
647 partitions: 1
648 retention:
649 hot_horus: 720
650";
651 assert!(matches!(
652 BridgeManifest::from_yaml(with_retention_typo),
653 Err(ManifestError::Parse(_))
654 ));
655 }
656
657 #[test]
658 fn validate_refuses_cold_uri_with_parent_directory_escape() {
659 for uri in [
663 "data/../../etc",
664 "../escape",
665 "/mnt/cold/../..",
666 "topics/../../..",
667 ] {
668 let mut m = BridgeManifest::default_for("t");
669 m.topics[0].retention.cold_uri = Some(uri.to_owned());
670 let err = m.validate().unwrap_err();
671 assert!(
672 matches!(err, ManifestError::Invalid(ref msg) if msg.contains("cold_uri") && msg.contains("..")),
673 "cold_uri {uri:?} must be refused as unsafe, got {err:?}"
674 );
675 }
676 let mut m = BridgeManifest::default_for("t");
680 m.topics[0].retention.cold_uri = Some("/mnt/cold".to_owned());
681 m.validate().unwrap();
682 let mut m = BridgeManifest::default_for("t");
685 m.topics[0].retention.cold_uri = Some("s3://bucket/../elsewhere".to_owned());
686 m.validate().unwrap();
687 }
688
689 #[test]
695 fn validate_refuses_empty_cold_uri_and_schema_ref() {
696 let mut m = BridgeManifest::default_for("t");
697 m.topics[0].retention.cold_uri = Some(String::new());
698 let err = m.validate().unwrap_err();
699 assert!(
700 matches!(err, ManifestError::Invalid(ref msg) if msg.contains("cold_uri") && msg.contains("empty")),
701 "empty cold_uri must be refused, got {err:?}"
702 );
703 let mut m = BridgeManifest::default_for("t");
704 m.topics[0].schema_ref = Some(String::new());
705 let err = m.validate().unwrap_err();
706 assert!(
707 matches!(err, ManifestError::Invalid(ref msg) if msg.contains("schema_ref") && msg.contains("empty")),
708 "empty schema_ref must be refused, got {err:?}"
709 );
710 }
711
712 #[test]
720 fn validate_refuses_fields_the_yaml_anchor_guard_cannot_reparse() {
721 for name in ["R&D-east", "region*star"] {
722 let mut m = BridgeManifest::default_for(name);
723 let err = m.validate().unwrap_err();
724 assert!(
725 matches!(err, ManifestError::Invalid(ref msg) if msg.contains("'&' or '*'")),
726 "name {name:?} must be refused with the round-trip rationale, got {err:?}"
727 );
728 m.name = name.to_owned();
731 let yaml = m.to_yaml().unwrap();
732 assert!(
733 matches!(BridgeManifest::from_yaml(&yaml), Err(ManifestError::Parse(_))),
734 "the anchor scan rejects the serialized {name:?} manifest"
735 );
736 }
737 let mut m = BridgeManifest::default_for("clean");
738 m.topics[0].retention.cold_uri = Some("s3://bucket/a&b".to_owned());
739 assert!(matches!(m.validate(), Err(ManifestError::Invalid(_))));
740 let mut m = BridgeManifest::default_for("clean");
741 m.topics[0].schema_ref = Some("schemas/a&b.json".to_owned());
742 assert!(matches!(m.validate(), Err(ManifestError::Invalid(_))));
743 let mut m = BridgeManifest::default_for("R-and-D-east");
745 m.topics[0].retention.cold_uri = Some("s3://bucket/prefix".to_owned());
746 m.validate().unwrap();
747 }
748
749 #[test]
750 fn malformed_yaml_is_a_parse_error() {
751 assert!(matches!(
752 BridgeManifest::from_yaml(":\n - not: [valid"),
753 Err(ManifestError::Parse(_))
754 ));
755 }
756
757 #[test]
758 fn default_manifest_matches_shipped_json_schema() {
759 let schema: serde_json::Value =
760 serde_json::from_str(include_str!("../../../schemas/bridge-manifest.schema.json")).unwrap();
761 let validator = jsonschema::validator_for(&schema).unwrap();
762 let value = serde_json::to_value(BridgeManifest::default_for("schema-test")).unwrap();
763 let errors: Vec<_> = validator.iter_errors(&value).collect();
764 assert!(errors.is_empty(), "{errors:?}");
765 }
766
767 #[test]
779 fn shipped_bridge_example_yaml_parses_and_validates() {
780 let yaml = include_str!("../../../manifests/bridge.example.yaml");
781 let manifest = BridgeManifest::from_yaml(yaml).unwrap_or_else(|error| {
782 panic!(
783 "shipped manifests/bridge.example.yaml must parse — the daemon calls \
784 BridgeManifest::from_yaml on the file at boot when \
785 bridge_manifest_path is set (the K8s ConfigMap and the README's \
786 avctl bridge-provision block both point at it). A parse failure \
787 here CrashLoopBackOffs the pod. Error: {error}"
788 )
789 });
790 manifest.validate().unwrap_or_else(|error| {
791 panic!(
792 "shipped manifests/bridge.example.yaml must pass BridgeManifest::\
793 validate() (the daemon calls this after from_yaml at boot). \
794 Error: {error}"
795 )
796 });
797 let schema: serde_json::Value =
799 serde_json::from_str(include_str!("../../../schemas/bridge-manifest.schema.json")).unwrap();
800 let validator = jsonschema::validator_for(&schema).unwrap();
801 let value = serde_json::to_value(&manifest).unwrap();
802 let errors: Vec<_> = validator.iter_errors(&value).collect();
803 assert!(
804 errors.is_empty(),
805 "shipped manifests/bridge.example.yaml must validate against \
806 schemas/bridge-manifest.schema.json (the schema downstream \
807 consumers use): {errors:?}"
808 );
809 assert_eq!(
816 manifest.replication_factor, 1,
817 "shipped bridge.example.yaml is the single-replica starter; \
818 bumping this without also bumping deploy/kubernetes/agentvisor-ai.yaml's \
819 Deployment.replicas ships an inconsistent example"
820 );
821 }
822
823 #[test]
832 fn topic_required_fields_agree_with_shipped_json_schema() {
833 let schema: serde_json::Value =
834 serde_json::from_str(include_str!("../../../schemas/bridge-manifest.schema.json")).unwrap();
835 let required: Vec<String> = schema
836 .pointer("/properties/topics/items/required")
837 .and_then(|v| v.as_array())
838 .unwrap()
839 .iter()
840 .filter_map(|v| v.as_str().map(str::to_owned))
841 .collect();
842 assert!(
843 required.contains(&"retention".to_owned()),
844 "test premise: schema must still require retention"
845 );
846
847 for field in &required {
848 let mut topic = serde_json::json!({
850 "name": "agent.tool_call",
851 "partitions": 1,
852 "retention": {"hot_hours": 720}
853 });
854 topic.as_object_mut().unwrap().remove(field);
855 let manifest = serde_json::json!({
856 "manifest_version": MANIFEST_VERSION,
857 "name": "drift-probe",
858 "topics": [topic],
859 });
860 let yaml = serde_yaml::to_string(&manifest).unwrap();
861 assert!(
862 BridgeManifest::from_yaml(&yaml).is_err(),
863 "topics[].{field} is required by the shipped JSON Schema; \
864 the Rust parser must refuse a manifest that omits it, or \
865 avctl manifest-validate splits verdict with every external \
866 schema linter"
867 );
868 }
869 }
870}
871
872#[cfg(test)]
873mod mutation_boundary_tests {
874 #![allow(
875 clippy::unwrap_used,
876 clippy::expect_used,
877 clippy::panic,
878 clippy::indexing_slicing
879 )]
880
881 use super::*;
882
883 #[test]
889 fn yaml_anchor_and_alias_markers_are_refused_per_name_class() {
890 let base = BridgeManifest::default_for("anchors").to_yaml().unwrap();
891 for snippet in ["&a1", "&_x", "&-a", "*a1", "*_x", "*-a"] {
896 let hostile = format!("{base}# {snippet}\n");
897 assert!(
898 BridgeManifest::from_yaml(&hostile).is_err(),
899 "{snippet} must be refused"
900 );
901 }
902 let benign = format!("{base}# tail & done * \n");
904 BridgeManifest::from_yaml(&benign).unwrap();
905 }
906
907 #[test]
910 fn manifest_size_cap_is_exact() {
911 let base = BridgeManifest::default_for("size-cap").to_yaml().unwrap();
912 let cap = 256 * 1024;
913 let pad_to = |len: usize| {
914 let mut s = base.clone();
915 s.push('#');
916 while s.len() < len {
917 s.push('x');
918 }
919 s
920 };
921 BridgeManifest::from_yaml(&pad_to(cap)).unwrap();
922 let over = BridgeManifest::from_yaml(&pad_to(cap + 1));
923 assert!(
924 matches!(over, Err(ManifestError::Parse(ref m)) if m.contains("exceeds cap")),
925 "one past the cap must refuse, got {over:?}"
926 );
927 }
928
929 #[test]
933 fn topic_name_dots_and_numeric_caps_are_exact() {
934 let mut m = BridgeManifest::default_for("dots");
935 for name in [".", ".."] {
936 m.topics[0].name = name.to_owned();
937 assert!(m.validate().is_err(), "topic name {name:?} must be refused");
938 }
939 m.topics[0].name = "agent.ok".to_owned();
940 m.topics[0].partitions = 1024;
941 m.topics[0].retention.hot_hours = 24 * 365 * 10;
942 m.validate().unwrap();
943 m.topics[0].partitions = 1025;
944 assert!(m.validate().is_err(), "1025 partitions past cap");
945 m.topics[0].partitions = 1;
946 m.topics[0].retention.hot_hours = 24 * 365 * 10 + 1;
947 assert!(m.validate().is_err(), "hot_hours past the 10y cap");
948 }
949
950 #[test]
955 #[cfg(any(feature = "nats", feature = "kafka"))]
956 fn compiled_topic_validators_reject_nonconforming_events() {
957 let mut m = BridgeManifest::default_for("schemas");
958 let dir = tempfile::tempdir().unwrap();
960 let schema_path = dir.path().join("strict.json");
961 std::fs::write(
962 &schema_path,
963 br#"{"type":"object","required":["metadata"],"properties":{"metadata":{"type":"object"}}}"#,
964 )
965 .unwrap();
966 for t in &mut m.topics {
967 t.schema_ref = None;
968 }
969 m.topics[0].schema_ref = Some(schema_path.to_string_lossy().into_owned());
970 let topic = m.topics[0].name.clone();
971 let validators = compile_topic_validators(&m).unwrap();
972 validate_topic_event(&validators, &topic, &serde_json::json!({"metadata": {}})).unwrap();
973 let refused = validate_topic_event(&validators, &topic, &serde_json::json!({"not": "conforming"}));
974 assert!(refused.is_err(), "nonconforming event must be refused");
975 }
976}