Skip to main content

av_bridge/
manifest.rs

1//! Declarative topic-schema manifest + provisioning (the Module F
2//! portability contract: "a customer stands up an identical bridge in a new
3//! region or air-gapped enclave from that manifest alone").
4
5use serde::{Deserialize, Serialize};
6#[cfg(any(feature = "nats", feature = "kafka"))]
7use std::collections::HashMap;
8use std::path::{Path, PathBuf};
9
10/// Manifest format version (evolution surface).
11pub const MANIFEST_VERSION: u32 = 1;
12
13/// Retention policy for one topic.
14#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(deny_unknown_fields)]
16pub struct RetentionSpec {
17    /// Hot retention window in hours (default 720 = 30 days, per the brief).
18    pub hot_hours: u32,
19    /// Optional cold-tier export directory/bucket URI (customer-owned storage).
20    #[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/// One topic declaration.
34#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
35#[serde(deny_unknown_fields)]
36pub struct TopicSpec {
37    /// Topic name (`agent.tool_call`, …).
38    pub name: String,
39    /// Partition count (partitioned by `ai_agent.instance_uid`).
40    pub partitions: u32,
41    /// Retention policy. Required (declared as such in the shipped
42    /// JSON Schema at `schemas/bridge-manifest.schema.json`). If we
43    /// silently defaulted here the `avctl manifest-validate` verdict
44    /// would split from every external JSON-Schema linter's — an
45    /// operator seeing the CLI's OK plus their platform gate's FAIL
46    /// on the same file has to guess which is correct. Retention is
47    /// also semantically load-bearing: an operator who reads the
48    /// schema's `hot_hours: default: 720` and omits the block gets
49    /// 30 days of hot storage they never asked for.
50    pub retention: RetentionSpec,
51    /// JSON Schema reference events on this topic must satisfy.
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    pub schema_ref: Option<String>,
54}
55
56/// The Bridge manifest.
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
58#[serde(deny_unknown_fields)]
59pub struct BridgeManifest {
60    /// Manifest format version.
61    pub manifest_version: u32,
62    /// Deployment name (region/enclave label).
63    pub name: String,
64    /// Broker replication factor for managed Kafka/NATS deployments.
65    #[serde(default = "default_replication_factor")]
66    pub replication_factor: u32,
67    /// Topic declarations.
68    pub topics: Vec<TopicSpec>,
69}
70
71fn default_replication_factor() -> u32 {
72    1
73}
74
75/// Manifest validation errors.
76#[derive(Debug, thiserror::Error, PartialEq, Eq)]
77#[non_exhaustive]
78pub enum ManifestError {
79    /// Unsupported version.
80    #[error("unsupported manifest_version {0} (this build supports {MANIFEST_VERSION})")]
81    Version(u32),
82    /// YAML/JSON parse failure.
83    #[error("manifest parse: {0}")]
84    Parse(String),
85    /// Structural problem.
86    #[error("manifest invalid: {0}")]
87    Invalid(String),
88}
89
90impl BridgeManifest {
91    /// The default manifest covering every OCSF event class topic.
92    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    /// Parse from YAML.
110    ///
111    /// Enforces a 256 KiB input cap and rejects any document containing
112    /// YAML anchors (`&`) or aliases (`*`). Together these close the
113    /// billion-laughs attack surface: `serde_yaml` 0.9 expands aliases
114    /// eagerly with no depth or expansion cap, so a maliciously crafted
115    /// manifest of a few KiB would OOM the process at parse time.
116    /// Legitimate bridge manifests never need aliases — reject rather
117    /// than trying to bound expansion, which would require a fork.
118    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        // Cheap syntactic scan for YAML aliases. Legitimate anchors/
127        // aliases would need explicit `&name`/`*name` syntax; a naive
128        // string search catches every case that serde_yaml would
129        // actually expand. False positives on strings that happen to
130        // contain `&` or `*` are reduced (not eliminated — the scan is
131        // not YAML-context-aware, so a quoted string like `"a &name"`
132        // still trips it; failing closed is acceptable for manifests)
133        // by checking that the char precedes a valid anchor-name
134        // character. Note libyaml's anchor-name
135        // class is alphanumeric | `_` | `-` (unsafe-libyaml IS_ALPHA);
136        // the original predicate omitted `-`, so hyphen-led anchors
137        // (`&-a`) expanded while evading this guard — empirically
138        // confirmed against the workspace's serde_yaml and pinned by
139        // the hyphen regression test below.
140        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    /// Serialize to YAML.
164    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    /// Validate structural invariants.
169    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        // `from_yaml`'s anchor/alias scan (the billion-laughs guard)
177        // rejects any '&'/'*' followed by an anchor-name character
178        // anywhere in the document — including inside quoted strings.
179        // Refuse those characters in every free-text field `to_yaml`
180        // serializes, so `provision()` can never persist a manifest that
181        // `open()` then refuses to re-parse (a name like "R&D-east" used
182        // to provision once and permanently brick the directory as
183        // already-provisioned-but-unopenable).
184        refuse_yaml_marker_chars("name", &self.name)?;
185        if self.topics.is_empty() {
186            return Err(ManifestError::Invalid("no topics declared".into()));
187        }
188        // Schema parity: the shipped bridge-manifest schema pins
189        // `topics.maxItems: 256`; without the same cap here a manifest
190        // that external schema tooling rejects passed `avctl
191        // manifest-validate` (split offline verdicts), and the count
192        // was otherwise bounded only indirectly by the YAML byte cap.
193        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        // The embedded broker uses topic
212        // names verbatim as directory names, so on case-insensitive
213        // filesystems (macOS APFS/HFS+ — the documented dev platform)
214        // two topics differing only by case ("Audit" vs "audit")
215        // resolve to ONE directory and silently interleave segments,
216        // offsets, and idempotency state. Reject case-insensitive
217        // collisions everywhere so a manifest behaves identically on
218        // macOS and Linux (Kafka refuses the same collision).
219        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        // The NATS backend maps topics to stream
229        // names via `.`→`_` (`av_{topic.replace('.', "_")}`), which is
230        // not injective: `agent.x` and `agent_x` collide onto stream
231        // `av_agent_x`, and the second provision's update_stream
232        // silently REPLACES the first topic's subjects — destroying its
233        // capture. Reject the collision at manifest validation for all
234        // backends so a manifest is portable across them.
235        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            // Length parity with the schema's `maxLength: 249`. 249 is
260            // Kafka's documented topic-name limit — the tightest real
261            // backend bound, and safely below filesystem NAME_MAX (255)
262            // for the embedded broker's per-topic directory and NATS's
263            // `av_`-prefixed stream name. Without a Rust-side bound, a
264            // longer name passed `avctl manifest-validate` while
265            // external schema tooling rejected it (split verdicts) and
266            // only failed at provision time.
267            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            // Cap partitions and hot_hours. `partitions` is
281            // u32, and without a cap `partitions: 4294967295` would
282            // pass validate() and drive the embedded broker to create
283            // 4 billion partition directories on startup — guaranteed
284            // inode/OOM exhaustion. 1024 matches the practical Kafka
285            // per-topic sanity cap. 87_600 hours is 10 years —
286            // realistic ceiling for a long-lived audit trail.
287            const MAX_PARTITIONS: u32 = 1024;
288            const MAX_HOT_HOURS: u32 = 24 * 365 * 10;
289            /// Schema parity: bridge-manifest.schema.json's `maxLength`
290            /// for `schema_ref` and `cold_uri`.
291            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                // schema_ref and cold_uri are the remaining serialized
312                // fields that admit '&'/'*' (topic names are already
313                // restricted to [A-Za-z0-9._-]); same round-trip rationale
314                // as the deployment-name check above.
315                refuse_yaml_marker_chars("schema_ref", reference)?;
316                // Schema parity: bridge-manifest.schema.json pins
317                // minLength 1 and maxLength 4096 on schema_ref and
318                // cold_uri — a value passing here while external schema
319                // validation refuses it would split the two verification
320                // paths.
321                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                // Schema parity (minLength 1) AND retention safety: an
345                // empty cold_uri reaches retention enforcement as
346                // `Path::new("")`, exporting the cold archive relative
347                // to the process CWD before the hot records are deleted
348                // — evidence lands wherever the daemon happened to be
349                // started. Refuse it up front.
350                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                // A local (non-scheme) cold_uri
363                // is used directly as a filesystem root at retention
364                // enforcement time (`Path::new(cold).join(...)` in
365                // embedded.rs). A `..` component would let the cold-
366                // archive tree land outside the intended prefix — a
367                // manifest-supplied path escape (CWE-22 class). The
368                // scheme forms (`s3://`, `gs://`, `file://`) are
369                // handled by the cold-store feature layer and are not
370                // subject to this check. Absolute paths are allowed:
371                // legitimate deployments point `cold_uri` at a
372                // dedicated partition or mount (e.g. `/mnt/cold`).
373                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
392/// Reject '&' and '*' in a free-text manifest field. These are the exact
393/// markers `from_yaml`'s anchor/alias scan keys on; allowing them in a
394/// serialized field would let `to_yaml` produce a document the scan
395/// refuses to re-parse. Rejected wholesale (not just marker+name-char)
396/// so the rule stays obvious and robust against scan refinements.
397fn 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        // The embedded copy lives INSIDE
454        // the crate (`crates/av-bridge/schemas/`) — an `include_str!`
455        // that reaches outside the package root breaks `cargo package`
456        // (and therefore every crates.io publish). A sync test in this
457        // module asserts byte-equality with the workspace-level copy.
458        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        // Cap the read (parity with the schema-read cap in embedded.rs): a hostile
464        // plant of a multi-GiB schema file would OOM the process before
465        // the JSON parser could complain.
466        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    /// The crate-local embedded schema
494    /// copy must stay byte-identical to the canonical workspace-level
495    /// artifact external consumers validate against. Reads the
496    /// workspace copy at runtime (not include_str!) so the packaged
497    /// crate's tests skip cleanly when the workspace file is absent.
498    #[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            // Packaged-crate build: no workspace copy to compare with.
504            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    /// Pin the two schema-parity caps at their exact boundaries: 256
525    /// topics is the inclusive max (schema maxItems) and 249 bytes is
526    /// the inclusive topic-name max (Kafka's limit, below NAME_MAX).
527    #[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)); // 249 bytes total
545        m.validate().expect("249-byte topic name is the inclusive max");
546        m.topics[0].name = format!("agent.{}", "a".repeat(244)); // 250 bytes
547        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        // Schema parity: bridge-manifest.schema.json pins maxLength 4096
577        // on schema_ref and cold_uri — boundary: 4096 passes, 4097 refused.
578        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        // Upper caps on partitions and hot_hours. Absurd
602        // values (`u32::MAX`) used to pass validate() and would drive
603        // EmbeddedBroker::provision to create 4 billion partition
604        // directories at startup — guaranteed inode/OOM exhaustion.
605        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    /// `deny_unknown_fields` on `BridgeManifest`,
614    /// `TopicSpec`, and `RetentionSpec` catches config-file typos
615    /// that would otherwise silently disable features. `cold_url`
616    /// (misspelling `cold_uri`) or `hot_days` (misspelling
617    /// `hot_hours`) used to pass `avctl manifest-validate` cleanly
618    /// and cold export or retention would silently do nothing.
619    #[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        // A local (non-scheme) cold_uri with
660        // `..` components would let retention enforcement write cold
661        // objects outside the intended prefix. Verify each shape.
662        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        // Absolute paths without `..` are the operator's choice and
677        // remain allowed (a dedicated `/mnt/cold` partition is a
678        // legitimate deployment shape).
679        let mut m = BridgeManifest::default_for("t");
680        m.topics[0].retention.cold_uri = Some("/mnt/cold".to_owned());
681        m.validate().unwrap();
682        // Scheme URIs skip the check entirely — the cold-store feature
683        // layer parses them.
684        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    /// Schema parity: bridge-manifest.schema.json pins `minLength: 1` on
690    /// both `cold_uri` and `schema_ref`. An empty `cold_uri` additionally
691    /// reaches retention enforcement as `Path::new("")` — the cold
692    /// archive would be exported relative to the process CWD before the
693    /// hot records are deleted.
694    #[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    /// A manifest that passes validate() must round-trip through
713    /// to_yaml/from_yaml. The from_yaml anchor scan rejects '&'/'*'
714    /// followed by an anchor-name character even inside quoted strings,
715    /// so validate() must refuse those characters in every free-text
716    /// field to_yaml serializes — otherwise provision() writes a
717    /// manifest that open() can never re-parse and the directory is
718    /// permanently "already provisioned" yet unopenable.
719    #[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            // And the refusal is exactly what saves the round-trip: the
729            // serialized form is indeed unparseable by from_yaml.
730            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        // Marker-free free-text fields stay legal.
744        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    /// Register pass 35 finding — the shipped manifests/bridge.example.
768    /// yaml is what the K8s ConfigMap points `bridge_manifest_path` at
769    /// (and what `avctl bridge-provision --manifest` consumes in the
770    /// README's copy-pasteable block). The default in-code manifest is
771    /// pinned by `default_manifest_matches_shipped_json_schema`, but
772    /// the on-disk YAML the daemon loads at boot was unpinned. If it
773    /// drifts (YAML anchor bomb, unknown topic key, missing field,
774    /// schema-ref rename), the daemon boots, calls
775    /// `BridgeManifest::from_yaml` on the file, and errors out with
776    /// `bridge_manifest: parse error at ...` — a CrashLoopBackOff on
777    /// Kubernetes, an immediate exit on bare-metal.
778    #[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        // Same schema conformance the default in-code manifest gets.
798        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        // Pin a specific piece of the shipped semantics: the manifest
810        // is single-broker (replication_factor 1) — matching the K8s
811        // ConfigMap's `bridge_backend = "embedded"` single-replica
812        // deployment. Bumping the default replication_factor here
813        // without matching the K8s manifest's Deployment `replicas`
814        // would ship an inconsistent example.
815        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    /// Split-verdict guard: for every field the shipped JSON Schema
824    /// declares required at `topics[].`, the Rust `from_yaml` parser
825    /// must ALSO refuse a manifest that omits it. Any mismatch here
826    /// means `avctl manifest-validate` and an external JSON-Schema
827    /// linter return different verdicts on the same file — the exact
828    /// class of drift that used to hide behind `#[serde(default)]` on
829    /// `TopicSpec.retention` (schema said required, Rust silently
830    /// defaulted to `hot_hours: 720`).
831    #[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            // Build a minimal one-topic manifest with `field` removed.
849            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    /// Mutation-run hardening: the alias scan's `||` -> `&&`
884    /// survivor would only refuse markers followed by BOTH alphanumeric
885    /// and `_` (impossible), silently re-opening the YAML anchor-bomb
886    /// vector for `_`-named anchors. Pin each marker/name-class combo,
887    /// and keep innocuous `&`/`*` usage legal.
888    #[test]
889    fn yaml_anchor_and_alias_markers_are_refused_per_name_class() {
890        let base = BridgeManifest::default_for("anchors").to_yaml().unwrap();
891        // `&-a`/`*-a` included — libyaml's anchor-name class is
892        // alphanumeric | `_` | `-`, and hyphen-led anchors demonstrably
893        // expand in serde_yaml; omitting `-` from the scan re-opens the
894        // billion-laughs vector for hyphen-named anchors.
895        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        // A trailing bare marker (no name character) stays legal.
903        let benign = format!("{base}# tail & done * \n");
904        BridgeManifest::from_yaml(&benign).unwrap();
905    }
906
907    /// The 256 KiB manifest cap must be exact: at-cap parses, one byte
908    /// past is refused with the cap message (not a YAML error).
909    #[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    /// Path-traversal topic names: `.` and `..` are refused explicitly
930    /// (they pass the character filter), and the partition/hot-hours caps
931    /// hold exactly at their boundaries.
932    #[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    /// `validate_topic_event -> Ok(())` survived: with schema_refs set,
951    /// a compile+validate round-trip must actually reject a nonconforming
952    /// event (this is the schema-enforcement path the Kafka and NATS
953    /// connectors share).
954    #[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        // Give one topic an inline-file schema requiring an object.
959        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}