Skip to main content

eggress_testkit/
composition.rs

1//! Composition matrix validation for the pproxy parity model.
2//!
3//! Validates `docs/parity/composition_matrix.toml` — the machine-readable
4//! composition graph that maps protocol×role×traffic_kind combinations to
5//! capability IDs and evidence.
6//!
7//! The composition matrix complements the flat capability manifest by
8//! preventing false parity claims: a protocol cannot be claimed as
9//! supported merely because one isolated implementation exists.
10
11use std::collections::HashSet;
12use std::fs;
13use std::path::{Path, PathBuf};
14
15use serde::Deserialize;
16use thiserror::Error;
17
18// ---------------------------------------------------------------------------
19// Constants
20// ---------------------------------------------------------------------------
21
22/// Allowed protocol values in the composition matrix.
23pub const ALLOWED_PROTOCOLS: &[&str] = &[
24    "direct",
25    "http",
26    "https",
27    "socks4",
28    "socks4a",
29    "socks5",
30    "shadowsocks",
31    "trojan",
32    "ssh",
33    "ws",
34    "wss",
35    "raw",
36    "tunnel",
37    "h2",
38    "quic",
39    "h3",
40    "unix",
41    "redir",
42];
43
44/// Allowed role values.
45pub const ALLOWED_ROLES: &[&str] = &[
46    "listener",
47    "upstream",
48    "chain_hop",
49    "terminal",
50    "reverse_server",
51    "reverse_client",
52];
53
54/// Allowed traffic kind values.
55pub const ALLOWED_TRAFFIC_KINDS: &[&str] = &["tcp", "udp"];
56
57/// Allowed tier values (same as manifest).
58pub const ALLOWED_TIERS: &[&str] = &[
59    "drop_in",
60    "compatible_with_warning",
61    "native_equivalent",
62    "intentional_non_parity",
63    "unsupported",
64];
65
66/// Allowed evidence values (same as manifest).
67pub const ALLOWED_EVIDENCE: &[&str] = &[
68    "differential",
69    "integration",
70    "unit",
71    "synthetic",
72    "docs_only",
73    "none",
74];
75
76/// Allowed constraint types.
77pub const ALLOWED_CONSTRAINT_TYPES: &[&str] = &[
78    "chain_max_hops",
79    "platform",
80    "requires_tls",
81    "no_udp",
82    "no_chain",
83    "protocol_crate_only",
84    "upstream_only_no_listener",
85];
86
87/// Allowed caveat class values.
88pub const ALLOWED_CAVEAT_CLASSES: &[&str] = &[
89    "protocol_crate_only",
90    "missing_protocol_command",
91    "missing_protocol_role",
92    "missing_protocol_transport",
93    "deferred_by_adr",
94    "intentional_non_parity",
95    "cli_process_model",
96    "translator_scope_gap",
97];
98
99/// Pinned schema version.
100pub const PINNED_SCHEMA_VERSION: &str = "1";
101
102// ---------------------------------------------------------------------------
103// Data model
104// ---------------------------------------------------------------------------
105
106/// Top-level matrix metadata.
107#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
108pub struct CompositionMatrixMeta {
109    pub schema_version: String,
110    pub manifest_ref: String,
111    #[serde(default)]
112    pub description: String,
113}
114
115/// A single composition cell mapping protocol×role×traffic_kind to capabilities.
116#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
117pub struct CompositionCell {
118    pub protocol: String,
119    pub role: String,
120    pub traffic_kind: String,
121    pub tier: String,
122    pub evidence: String,
123    #[serde(default)]
124    pub capability_ids: Vec<String>,
125    #[serde(default)]
126    pub notes: String,
127    #[serde(default)]
128    pub caveat_class: String,
129    #[serde(default)]
130    pub rationale: String,
131    /// Optional chain max hops constraint for this cell.
132    pub chain_max: Option<u32>,
133}
134
135/// A chain composition (listener->upstream transition).
136#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
137pub struct ChainComposition {
138    pub from_protocol: String,
139    pub to_protocol: String,
140    pub traffic_kind: String,
141    pub tier: String,
142    pub evidence: String,
143    #[serde(default)]
144    pub capability_ids: Vec<String>,
145    #[serde(default)]
146    pub notes: String,
147}
148
149/// A global constraint on the composition matrix.
150#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
151pub struct CompositionConstraint {
152    #[serde(rename = "type")]
153    pub constraint_type: String,
154    #[serde(default)]
155    pub value: Option<u32>,
156    #[serde(default)]
157    pub applies_to: Vec<String>,
158    #[serde(default)]
159    pub description: String,
160}
161
162/// The parsed composition matrix.
163#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
164pub struct CompositionMatrix {
165    pub matrix: CompositionMatrixMeta,
166    #[serde(default)]
167    pub cell: Vec<CompositionCell>,
168    #[serde(default)]
169    pub chain: Vec<ChainComposition>,
170    #[serde(default)]
171    pub constraint: Vec<CompositionConstraint>,
172}
173
174// ---------------------------------------------------------------------------
175// Validation errors
176// ---------------------------------------------------------------------------
177
178/// A single validation error or warning.
179#[derive(Debug, Clone, Error, PartialEq, Eq)]
180pub enum CompositionValidationError {
181    #[error("schema version mismatch: got {got}, expected {expected}")]
182    SchemaVersionMismatch { got: String, expected: String },
183
184    #[error("unknown protocol: {0}")]
185    UnknownProtocol(String),
186
187    #[error("unknown role: {0}")]
188    UnknownRole(String),
189
190    #[error("unknown traffic_kind: {0}")]
191    UnknownTrafficKind(String),
192
193    #[error("unknown tier: {0}")]
194    UnknownTier(String),
195
196    #[error("unknown evidence: {0}")]
197    UnknownEvidence(String),
198
199    #[error("unknown constraint type: {0}")]
200    UnknownConstraintType(String),
201
202    #[error("unknown caveat_class: {0}")]
203    UnknownCaveatClass(String),
204
205    #[error("duplicate cell: protocol={protocol} role={role} traffic_kind={traffic_kind}")]
206    DuplicateCell {
207        protocol: String,
208        role: String,
209        traffic_kind: String,
210    },
211
212    #[error("duplicate chain: from={from_protocol} to={to_protocol} traffic_kind={traffic_kind}")]
213    DuplicateChain {
214        from_protocol: String,
215        to_protocol: String,
216        traffic_kind: String,
217    },
218
219    #[error("unsupported cell has non-empty capability_ids: protocol={protocol} role={role}")]
220    UnsupportedCellWithCapabilities { protocol: String, role: String },
221
222    #[error("protocol-crate-only cell has tier=drop_in: {0}")]
223    ProtocolCrateOnlyDropIn(String),
224
225    #[error("drop_in cell with evidence weaker than integration: {protocol}/{role}")]
226    DropInWeakEvidence { protocol: String, role: String },
227
228    #[error("chain composition missing from_protocol or to_protocol")]
229    ChainMissingProtocols,
230
231    #[error("chain composition with chain_max < 2: {from_protocol} -> {to_protocol}")]
232    ChainMaxTooSmall {
233        from_protocol: String,
234        to_protocol: String,
235    },
236
237    #[error("capability_id not found in manifest: {0}")]
238    UnknownCapabilityId(String),
239
240    #[error("constraint applies_to references unknown protocol: {0}")]
241    ConstraintUnknownProtocol(String),
242
243    #[error("empty composition matrix (no cells or chains)")]
244    EmptyMatrix,
245
246    #[error("io error: {0}")]
247    IoError(String),
248
249    #[error("toml parse error: {0}")]
250    ParseError(String),
251}
252
253impl From<toml::de::Error> for CompositionValidationError {
254    fn from(e: toml::de::Error) -> Self {
255        CompositionValidationError::ParseError(e.to_string())
256    }
257}
258
259/// Collection of validation errors and warnings.
260#[derive(Debug, Clone, Default, PartialEq, Eq)]
261pub struct CompositionValidationResult {
262    pub errors: Vec<CompositionValidationError>,
263    pub warnings: Vec<CompositionValidationError>,
264}
265
266impl CompositionValidationResult {
267    pub fn push_error(&mut self, err: CompositionValidationError) {
268        self.errors.push(err);
269    }
270
271    pub fn push_warning(&mut self, warn: CompositionValidationError) {
272        self.warnings.push(warn);
273    }
274
275    pub fn is_empty(&self) -> bool {
276        self.errors.is_empty() && self.warnings.is_empty()
277    }
278
279    pub fn has_errors(&self) -> bool {
280        !self.errors.is_empty()
281    }
282
283    pub fn len(&self) -> usize {
284        self.errors.len() + self.warnings.len()
285    }
286}
287
288// ---------------------------------------------------------------------------
289// Validation
290// ---------------------------------------------------------------------------
291
292/// Validate a composition matrix against the schema and manifest.
293///
294/// Returns `Ok(())` on success, or `Err(result)` with errors/warnings.
295pub fn validate_composition_matrix(
296    matrix: &CompositionMatrix,
297    manifest_capability_ids: &HashSet<&str>,
298) -> Result<(), CompositionValidationResult> {
299    let mut result = CompositionValidationResult::default();
300
301    // Schema version check
302    if matrix.matrix.schema_version != PINNED_SCHEMA_VERSION {
303        result.push_error(CompositionValidationError::SchemaVersionMismatch {
304            got: matrix.matrix.schema_version.clone(),
305            expected: PINNED_SCHEMA_VERSION.to_string(),
306        });
307    }
308
309    // Empty matrix check
310    if matrix.cell.is_empty() && matrix.chain.is_empty() {
311        result.push_error(CompositionValidationError::EmptyMatrix);
312    }
313
314    // Validate cells
315    let mut seen_cells: HashSet<(&str, &str, &str)> = HashSet::new();
316    for cell in &matrix.cell {
317        if !ALLOWED_PROTOCOLS.contains(&cell.protocol.as_str()) {
318            result.push_error(CompositionValidationError::UnknownProtocol(
319                cell.protocol.clone(),
320            ));
321        }
322
323        if !ALLOWED_ROLES.contains(&cell.role.as_str()) {
324            result.push_error(CompositionValidationError::UnknownRole(cell.role.clone()));
325        }
326
327        if !ALLOWED_TRAFFIC_KINDS.contains(&cell.traffic_kind.as_str()) {
328            result.push_error(CompositionValidationError::UnknownTrafficKind(
329                cell.traffic_kind.clone(),
330            ));
331        }
332
333        if !ALLOWED_TIERS.contains(&cell.tier.as_str()) {
334            result.push_error(CompositionValidationError::UnknownTier(cell.tier.clone()));
335        }
336
337        if !ALLOWED_EVIDENCE.contains(&cell.evidence.as_str()) {
338            result.push_error(CompositionValidationError::UnknownEvidence(
339                cell.evidence.clone(),
340            ));
341        }
342
343        if !cell.caveat_class.is_empty()
344            && !ALLOWED_CAVEAT_CLASSES.contains(&cell.caveat_class.as_str())
345        {
346            result.push_error(CompositionValidationError::UnknownCaveatClass(
347                cell.caveat_class.clone(),
348            ));
349        }
350
351        let key = (
352            cell.protocol.as_str(),
353            cell.role.as_str(),
354            cell.traffic_kind.as_str(),
355        );
356        if !seen_cells.insert(key) {
357            result.push_error(CompositionValidationError::DuplicateCell {
358                protocol: cell.protocol.clone(),
359                role: cell.role.clone(),
360                traffic_kind: cell.traffic_kind.clone(),
361            });
362        }
363
364        if cell.tier == "unsupported" && !cell.capability_ids.is_empty() {
365            result.push_error(
366                CompositionValidationError::UnsupportedCellWithCapabilities {
367                    protocol: cell.protocol.clone(),
368                    role: cell.role.clone(),
369                },
370            );
371        }
372
373        if cell.caveat_class == "protocol_crate_only" && cell.tier == "drop_in" {
374            result.push_error(CompositionValidationError::ProtocolCrateOnlyDropIn(
375                format!("{}/{}", cell.protocol, cell.role),
376            ));
377        }
378
379        if cell.tier == "drop_in"
380            && matches!(
381                cell.evidence.as_str(),
382                "unit" | "synthetic" | "docs_only" | "none"
383            )
384        {
385            result.push_warning(CompositionValidationError::DropInWeakEvidence {
386                protocol: cell.protocol.clone(),
387                role: cell.role.clone(),
388            });
389        }
390
391        for cap_id in &cell.capability_ids {
392            if !manifest_capability_ids.contains(cap_id.as_str()) {
393                result.push_error(CompositionValidationError::UnknownCapabilityId(
394                    cap_id.clone(),
395                ));
396            }
397        }
398    }
399
400    // Validate chains
401    let mut seen_chains: HashSet<(&str, &str, &str)> = HashSet::new();
402    for chain in &matrix.chain {
403        if !ALLOWED_PROTOCOLS.contains(&chain.from_protocol.as_str()) {
404            result.push_error(CompositionValidationError::UnknownProtocol(
405                chain.from_protocol.clone(),
406            ));
407        }
408        if !ALLOWED_PROTOCOLS.contains(&chain.to_protocol.as_str()) {
409            result.push_error(CompositionValidationError::UnknownProtocol(
410                chain.to_protocol.clone(),
411            ));
412        }
413
414        if !ALLOWED_TRAFFIC_KINDS.contains(&chain.traffic_kind.as_str()) {
415            result.push_error(CompositionValidationError::UnknownTrafficKind(
416                chain.traffic_kind.clone(),
417            ));
418        }
419
420        if !ALLOWED_TIERS.contains(&chain.tier.as_str()) {
421            result.push_error(CompositionValidationError::UnknownTier(chain.tier.clone()));
422        }
423
424        if !ALLOWED_EVIDENCE.contains(&chain.evidence.as_str()) {
425            result.push_error(CompositionValidationError::UnknownEvidence(
426                chain.evidence.clone(),
427            ));
428        }
429
430        let key = (
431            chain.from_protocol.as_str(),
432            chain.to_protocol.as_str(),
433            chain.traffic_kind.as_str(),
434        );
435        if !seen_chains.insert(key) {
436            result.push_error(CompositionValidationError::DuplicateChain {
437                from_protocol: chain.from_protocol.clone(),
438                to_protocol: chain.to_protocol.clone(),
439                traffic_kind: chain.traffic_kind.clone(),
440            });
441        }
442
443        for cap_id in &chain.capability_ids {
444            if !manifest_capability_ids.contains(cap_id.as_str()) {
445                result.push_error(CompositionValidationError::UnknownCapabilityId(
446                    cap_id.clone(),
447                ));
448            }
449        }
450    }
451
452    // Validate constraints
453    for constraint in &matrix.constraint {
454        if !ALLOWED_CONSTRAINT_TYPES.contains(&constraint.constraint_type.as_str()) {
455            result.push_error(CompositionValidationError::UnknownConstraintType(
456                constraint.constraint_type.clone(),
457            ));
458        }
459
460        for proto in &constraint.applies_to {
461            if !ALLOWED_PROTOCOLS.contains(&proto.as_str()) {
462                result.push_error(CompositionValidationError::ConstraintUnknownProtocol(
463                    proto.clone(),
464                ));
465            }
466        }
467    }
468
469    if result.errors.is_empty() {
470        Ok(())
471    } else {
472        Err(result)
473    }
474}
475
476/// Parse and validate a composition matrix from a TOML file.
477pub fn validate_composition_matrix_file(
478    path: &Path,
479    manifest_capability_ids: &HashSet<&str>,
480) -> Result<CompositionMatrix, CompositionValidationResult> {
481    let content = fs::read_to_string(path).map_err(|e| {
482        let mut result = CompositionValidationResult::default();
483        result.push_error(CompositionValidationError::SchemaVersionMismatch {
484            got: format!("read error: {}", e),
485            expected: "valid TOML file".to_string(),
486        });
487        result
488    })?;
489
490    let matrix: CompositionMatrix = toml::from_str(&content).map_err(|e| {
491        let mut result = CompositionValidationResult::default();
492        result.push_error(CompositionValidationError::SchemaVersionMismatch {
493            got: format!("parse error: {}", e),
494            expected: "valid TOML structure".to_string(),
495        });
496        result
497    })?;
498
499    validate_composition_matrix(&matrix, manifest_capability_ids)?;
500    Ok(matrix)
501}
502
503/// Find the composition matrix file path relative to the workspace root.
504pub fn find_composition_matrix_path() -> Option<PathBuf> {
505    let candidates = [
506        "docs/parity/composition_matrix.toml",
507        "../docs/parity/composition_matrix.toml",
508        "../../docs/parity/composition_matrix.toml",
509    ];
510
511    for candidate in &candidates {
512        let path = Path::new(candidate);
513        if path.exists() {
514            return Some(path.to_path_buf());
515        }
516    }
517
518    None
519}
520
521/// Query whether a specific composition is supported.
522pub fn query_composition<'a>(
523    matrix: &'a CompositionMatrix,
524    protocol: &str,
525    role: &str,
526    traffic_kind: &str,
527) -> Option<&'a CompositionCell> {
528    matrix
529        .cell
530        .iter()
531        .find(|c| c.protocol == protocol && c.role == role && c.traffic_kind == traffic_kind)
532}
533
534/// Query whether a chain composition is supported.
535pub fn query_chain<'a>(
536    matrix: &'a CompositionMatrix,
537    from_protocol: &str,
538    to_protocol: &str,
539    traffic_kind: &str,
540) -> Option<&'a ChainComposition> {
541    matrix.chain.iter().find(|c| {
542        c.from_protocol == from_protocol
543            && c.to_protocol == to_protocol
544            && c.traffic_kind == traffic_kind
545    })
546}
547
548/// Get all supported protocols for a given role and traffic kind.
549pub fn supported_protocols<'a>(
550    matrix: &'a CompositionMatrix,
551    role: &str,
552    traffic_kind: &str,
553) -> Vec<&'a str> {
554    matrix
555        .cell
556        .iter()
557        .filter(|c| c.role == role && c.traffic_kind == traffic_kind && c.tier != "unsupported")
558        .map(|c| c.protocol.as_str())
559        .collect()
560}
561
562/// Get all supported roles for a given protocol and traffic kind.
563pub fn supported_roles<'a>(
564    matrix: &'a CompositionMatrix,
565    protocol: &str,
566    traffic_kind: &str,
567) -> Vec<&'a str> {
568    matrix
569        .cell
570        .iter()
571        .filter(|c| {
572            c.protocol == protocol && c.traffic_kind == traffic_kind && c.tier != "unsupported"
573        })
574        .map(|c| c.role.as_str())
575        .collect()
576}
577
578/// Count cells by tier.
579pub fn count_by_tier(matrix: &CompositionMatrix) -> std::collections::HashMap<String, usize> {
580    let mut counts = std::collections::HashMap::new();
581    for cell in &matrix.cell {
582        *counts.entry(cell.tier.clone()).or_insert(0) += 1;
583    }
584    counts
585}
586
587/// Count chain compositions by tier.
588pub fn count_chains_by_tier(
589    matrix: &CompositionMatrix,
590) -> std::collections::HashMap<String, usize> {
591    let mut counts = std::collections::HashMap::new();
592    for chain in &matrix.chain {
593        *counts.entry(chain.tier.clone()).or_insert(0) += 1;
594    }
595    counts
596}
597
598// ---------------------------------------------------------------------------
599// Tests
600// ---------------------------------------------------------------------------
601
602/// Runtime composition validator backed by a loaded matrix.
603///
604/// Wraps a `CompositionMatrix` and provides efficient query methods
605/// for checking whether a given protocol×role×traffic_kind combination
606/// is supported, and what tier/evidence it has.
607pub struct CompositionValidator {
608    matrix: CompositionMatrix,
609}
610
611impl CompositionValidator {
612    /// Load the composition matrix from the canonical path.
613    pub fn load() -> Option<Self> {
614        let path = find_composition_matrix_path()?;
615        let content = fs::read_to_string(&path).ok()?;
616        let matrix: CompositionMatrix = toml::from_str(&content).ok()?;
617        Some(Self { matrix })
618    }
619
620    /// Load from an explicit file path.
621    pub fn from_file(path: &Path) -> Result<Self, CompositionValidationError> {
622        let content = fs::read_to_string(path)
623            .map_err(|e| CompositionValidationError::IoError(format!("{}: {e}", path.display())))?;
624        let matrix: CompositionMatrix = toml::from_str(&content)?;
625        Ok(Self { matrix })
626    }
627
628    /// Query a single composition cell.
629    pub fn query(
630        &self,
631        protocol: &str,
632        role: &str,
633        traffic_kind: &str,
634    ) -> Option<&CompositionCell> {
635        query_composition(&self.matrix, protocol, role, traffic_kind)
636    }
637
638    /// Query a chain composition.
639    pub fn query_chain(
640        &self,
641        from_protocol: &str,
642        to_protocol: &str,
643        traffic_kind: &str,
644    ) -> Option<&ChainComposition> {
645        query_chain(&self.matrix, from_protocol, to_protocol, traffic_kind)
646    }
647
648    /// Check if a protocol+role+traffic_kind combination is supported (non-unsupported tier).
649    pub fn is_supported(&self, protocol: &str, role: &str, traffic_kind: &str) -> bool {
650        self.query(protocol, role, traffic_kind)
651            .map(|c| c.tier != "unsupported")
652            .unwrap_or(false)
653    }
654
655    /// Get all protocols supporting a given role+traffic_kind at or above a tier.
656    pub fn protocols_at_or_above_tier(
657        &self,
658        role: &str,
659        traffic_kind: &str,
660        min_tier: &str,
661    ) -> Vec<&str> {
662        let tier_order = |t: &str| match t {
663            "drop_in" => 0,
664            "compatible_with_warning" => 1,
665            "native_equivalent" => 2,
666            "intentional_non_parity" => 3,
667            "unsupported" => 4,
668            _ => 5,
669        };
670        let min_rank = tier_order(min_tier);
671
672        self.matrix
673            .cell
674            .iter()
675            .filter(|c| {
676                c.role == role && c.traffic_kind == traffic_kind && tier_order(&c.tier) <= min_rank
677            })
678            .map(|c| c.protocol.as_str())
679            .collect()
680    }
681
682    /// Get all constraints that apply to a given protocol.
683    pub fn constraints_for(&self, protocol: &str) -> Vec<&CompositionConstraint> {
684        self.matrix
685            .constraint
686            .iter()
687            .filter(|c| c.applies_to.iter().any(|p| p == protocol))
688            .collect()
689    }
690
691    /// Return a reference to the inner matrix.
692    pub fn inner(&self) -> &CompositionMatrix {
693        &self.matrix
694    }
695}
696
697#[cfg(test)]
698mod tests {
699    use super::*;
700
701    /// Derive the canonical manifest path from a composition matrix path.
702    fn manifest_path_from_matrix(matrix_path: &Path) -> PathBuf {
703        // composition_matrix.toml is in docs/parity/, manifest is sibling
704        matrix_path
705            .parent()
706            .unwrap_or(Path::new("."))
707            .join("pproxy_capability_manifest.toml")
708    }
709
710    fn minimal_matrix() -> CompositionMatrix {
711        CompositionMatrix {
712            matrix: CompositionMatrixMeta {
713                schema_version: PINNED_SCHEMA_VERSION.to_string(),
714                manifest_ref: "test.toml".to_string(),
715                description: "test".to_string(),
716            },
717            cell: vec![CompositionCell {
718                protocol: "socks5".to_string(),
719                role: "listener".to_string(),
720                traffic_kind: "tcp".to_string(),
721                tier: "drop_in".to_string(),
722                evidence: "integration".to_string(),
723                capability_ids: vec![],
724                notes: String::new(),
725                caveat_class: String::new(),
726                rationale: String::new(),
727                chain_max: None,
728            }],
729            chain: vec![],
730            constraint: vec![],
731        }
732    }
733
734    #[test]
735    fn valid_minimal_matrix() {
736        let matrix = minimal_matrix();
737        let caps = HashSet::new();
738        let result = validate_composition_matrix(&matrix, &caps);
739        assert!(result.is_ok(), "expected Ok, got {:?}", result.err());
740    }
741
742    #[test]
743    fn empty_matrix_rejected() {
744        let mut matrix = minimal_matrix();
745        matrix.cell.clear();
746        let caps = HashSet::new();
747        let result = validate_composition_matrix(&matrix, &caps);
748        assert!(result.is_err());
749        let err = result.unwrap_err();
750        assert!(err
751            .errors
752            .iter()
753            .any(|e| matches!(e, CompositionValidationError::EmptyMatrix)));
754    }
755
756    #[test]
757    fn unknown_protocol_rejected() {
758        let mut matrix = minimal_matrix();
759        matrix.cell[0].protocol = "bogus".to_string();
760        let caps = HashSet::new();
761        let result = validate_composition_matrix(&matrix, &caps);
762        assert!(result.is_err());
763        let err = result.unwrap_err();
764        assert!(err
765            .errors
766            .iter()
767            .any(|e| matches!(e, CompositionValidationError::UnknownProtocol(_))));
768    }
769
770    #[test]
771    fn unknown_role_rejected() {
772        let mut matrix = minimal_matrix();
773        matrix.cell[0].role = "bogus".to_string();
774        let caps = HashSet::new();
775        let result = validate_composition_matrix(&matrix, &caps);
776        assert!(result.is_err());
777        let err = result.unwrap_err();
778        assert!(err
779            .errors
780            .iter()
781            .any(|e| matches!(e, CompositionValidationError::UnknownRole(_))));
782    }
783
784    #[test]
785    fn unknown_tier_rejected() {
786        let mut matrix = minimal_matrix();
787        matrix.cell[0].tier = "bogus".to_string();
788        let caps = HashSet::new();
789        let result = validate_composition_matrix(&matrix, &caps);
790        assert!(result.is_err());
791        let err = result.unwrap_err();
792        assert!(err
793            .errors
794            .iter()
795            .any(|e| matches!(e, CompositionValidationError::UnknownTier(_))));
796    }
797
798    #[test]
799    fn unsupported_cell_with_capabilities_rejected() {
800        let mut matrix = minimal_matrix();
801        matrix.cell[0].tier = "unsupported".to_string();
802        matrix.cell[0].capability_ids = vec!["some.cap".to_string()];
803        let caps = HashSet::new();
804        let result = validate_composition_matrix(&matrix, &caps);
805        assert!(result.is_err());
806        let err = result.unwrap_err();
807        assert!(err.errors.iter().any(|e| matches!(
808            e,
809            CompositionValidationError::UnsupportedCellWithCapabilities { .. }
810        )));
811    }
812
813    #[test]
814    fn protocol_crate_only_drop_in_rejected() {
815        let mut matrix = minimal_matrix();
816        matrix.cell[0].protocol = "ws".to_string();
817        matrix.cell[0].tier = "drop_in".to_string();
818        matrix.cell[0].caveat_class = "protocol_crate_only".to_string();
819        let caps = HashSet::new();
820        let result = validate_composition_matrix(&matrix, &caps);
821        assert!(result.is_err());
822        let err = result.unwrap_err();
823        assert!(err
824            .errors
825            .iter()
826            .any(|e| matches!(e, CompositionValidationError::ProtocolCrateOnlyDropIn(_))));
827    }
828
829    #[test]
830    fn drop_in_with_weak_evidence_warns() {
831        let mut matrix = minimal_matrix();
832        matrix.cell[0].tier = "drop_in".to_string();
833        matrix.cell[0].evidence = "unit".to_string();
834        let caps = HashSet::new();
835        let result = validate_composition_matrix(&matrix, &caps);
836        // Ok(()) means no errors, but warnings are populated
837        assert!(result.is_ok());
838    }
839
840    #[test]
841    fn duplicate_cell_rejected() {
842        let mut matrix = minimal_matrix();
843        matrix.cell.push(CompositionCell {
844            protocol: "socks5".to_string(),
845            role: "listener".to_string(),
846            traffic_kind: "tcp".to_string(),
847            tier: "drop_in".to_string(),
848            evidence: "integration".to_string(),
849            capability_ids: vec![],
850            notes: String::new(),
851            caveat_class: String::new(),
852            rationale: String::new(),
853            chain_max: None,
854        });
855        let caps = HashSet::new();
856        let result = validate_composition_matrix(&matrix, &caps);
857        assert!(result.is_err());
858        let err = result.unwrap_err();
859        assert!(err
860            .errors
861            .iter()
862            .any(|e| matches!(e, CompositionValidationError::DuplicateCell { .. })));
863    }
864
865    #[test]
866    fn unknown_capability_id_rejected() {
867        let mut matrix = minimal_matrix();
868        matrix.cell[0].capability_ids = vec!["nonexistent.cap".to_string()];
869        let caps = HashSet::new();
870        let result = validate_composition_matrix(&matrix, &caps);
871        assert!(result.is_err());
872        let err = result.unwrap_err();
873        assert!(err
874            .errors
875            .iter()
876            .any(|e| matches!(e, CompositionValidationError::UnknownCapabilityId(_))));
877    }
878
879    #[test]
880    fn known_capability_id_accepted() {
881        let mut matrix = minimal_matrix();
882        matrix.cell[0].capability_ids = vec!["protocol.socks5.connect_ipv4".to_string()];
883        let mut caps = HashSet::new();
884        caps.insert("protocol.socks5.connect_ipv4");
885        let result = validate_composition_matrix(&matrix, &caps);
886        assert!(result.is_ok(), "expected Ok, got {:?}", result.err());
887    }
888
889    #[test]
890    fn chain_validated() {
891        let mut matrix = minimal_matrix();
892        matrix.chain.push(ChainComposition {
893            from_protocol: "socks5".to_string(),
894            to_protocol: "http".to_string(),
895            traffic_kind: "tcp".to_string(),
896            tier: "drop_in".to_string(),
897            evidence: "integration".to_string(),
898            capability_ids: vec![],
899            notes: String::new(),
900        });
901        let caps = HashSet::new();
902        let result = validate_composition_matrix(&matrix, &caps);
903        assert!(result.is_ok(), "expected Ok, got {:?}", result.err());
904    }
905
906    #[test]
907    fn chain_unknown_protocol_rejected() {
908        let mut matrix = minimal_matrix();
909        matrix.chain.push(ChainComposition {
910            from_protocol: "bogus".to_string(),
911            to_protocol: "http".to_string(),
912            traffic_kind: "tcp".to_string(),
913            tier: "drop_in".to_string(),
914            evidence: "integration".to_string(),
915            capability_ids: vec![],
916            notes: String::new(),
917        });
918        let caps = HashSet::new();
919        let result = validate_composition_matrix(&matrix, &caps);
920        assert!(result.is_err());
921        let err = result.unwrap_err();
922        assert!(err
923            .errors
924            .iter()
925            .any(|e| matches!(e, CompositionValidationError::UnknownProtocol(_))));
926    }
927
928    #[test]
929    fn schema_version_mismatch_rejected() {
930        let mut matrix = minimal_matrix();
931        matrix.matrix.schema_version = "99".to_string();
932        let caps = HashSet::new();
933        let result = validate_composition_matrix(&matrix, &caps);
934        assert!(result.is_err());
935        let err = result.unwrap_err();
936        assert!(err
937            .errors
938            .iter()
939            .any(|e| matches!(e, CompositionValidationError::SchemaVersionMismatch { .. })));
940    }
941
942    #[test]
943    fn constraint_unknown_type_rejected() {
944        let mut matrix = minimal_matrix();
945        matrix.constraint.push(CompositionConstraint {
946            constraint_type: "bogus".to_string(),
947            value: None,
948            applies_to: vec![],
949            description: "test".to_string(),
950        });
951        let caps = HashSet::new();
952        let result = validate_composition_matrix(&matrix, &caps);
953        assert!(result.is_err());
954        let err = result.unwrap_err();
955        assert!(err
956            .errors
957            .iter()
958            .any(|e| matches!(e, CompositionValidationError::UnknownConstraintType(_))));
959    }
960
961    #[test]
962    fn constraint_unknown_protocol_rejected() {
963        let mut matrix = minimal_matrix();
964        matrix.constraint.push(CompositionConstraint {
965            constraint_type: "no_udp".to_string(),
966            value: None,
967            applies_to: vec!["bogus".to_string()],
968            description: "test".to_string(),
969        });
970        let caps = HashSet::new();
971        let result = validate_composition_matrix(&matrix, &caps);
972        assert!(result.is_err());
973        let err = result.unwrap_err();
974        assert!(err
975            .errors
976            .iter()
977            .any(|e| matches!(e, CompositionValidationError::ConstraintUnknownProtocol(_))));
978    }
979
980    #[test]
981    fn query_composition_found() {
982        let matrix = minimal_matrix();
983        let cell = query_composition(&matrix, "socks5", "listener", "tcp");
984        assert!(cell.is_some());
985        assert_eq!(cell.unwrap().tier, "drop_in");
986    }
987
988    #[test]
989    fn query_composition_not_found() {
990        let matrix = minimal_matrix();
991        let cell = query_composition(&matrix, "http", "listener", "tcp");
992        assert!(cell.is_none());
993    }
994
995    #[test]
996    fn supported_protocols_lists_correct() {
997        let mut matrix = minimal_matrix();
998        matrix.cell.push(CompositionCell {
999            protocol: "http".to_string(),
1000            role: "listener".to_string(),
1001            traffic_kind: "tcp".to_string(),
1002            tier: "drop_in".to_string(),
1003            evidence: "integration".to_string(),
1004            capability_ids: vec![],
1005            notes: String::new(),
1006            caveat_class: String::new(),
1007            rationale: String::new(),
1008            chain_max: None,
1009        });
1010        let protos = supported_protocols(&matrix, "listener", "tcp");
1011        assert!(protos.contains(&"socks5"));
1012        assert!(protos.contains(&"http"));
1013    }
1014
1015    #[test]
1016    fn count_by_tier_works() {
1017        let mut matrix = minimal_matrix();
1018        matrix.cell.push(CompositionCell {
1019            protocol: "http".to_string(),
1020            role: "listener".to_string(),
1021            traffic_kind: "tcp".to_string(),
1022            tier: "drop_in".to_string(),
1023            evidence: "integration".to_string(),
1024            capability_ids: vec![],
1025            notes: String::new(),
1026            caveat_class: String::new(),
1027            rationale: String::new(),
1028            chain_max: None,
1029        });
1030        let counts = count_by_tier(&matrix);
1031        assert_eq!(counts.get("drop_in"), Some(&2));
1032    }
1033
1034    #[test]
1035    fn real_composition_matrix_validates() {
1036        let Some(matrix_path) = find_composition_matrix_path() else {
1037            return;
1038        };
1039
1040        // Load manifest capability IDs from the canonical manifest
1041        let manifest_path = manifest_path_from_matrix(&matrix_path);
1042        let mut manifest_ids: HashSet<&str> = HashSet::new();
1043        let manifest_content = match fs::read_to_string(&manifest_path) {
1044            Ok(c) => c,
1045            Err(_) => return, // manifest not available in this test environment
1046        };
1047        let manifest_value: toml::Value = match manifest_content.parse() {
1048            Ok(v) => v,
1049            Err(_) => return,
1050        };
1051        if let Some(caps) = manifest_value.get("capability").and_then(|v| v.as_array()) {
1052            for cap in caps {
1053                if let Some(id) = cap.get("id").and_then(|v| v.as_str()) {
1054                    manifest_ids.insert(id);
1055                }
1056            }
1057        }
1058
1059        let result = validate_composition_matrix_file(&matrix_path, &manifest_ids);
1060        assert!(
1061            result.is_ok(),
1062            "real composition matrix validation failed: {:?}",
1063            result.err()
1064        );
1065    }
1066
1067    #[test]
1068    fn composition_validator_load() {
1069        let validator = CompositionValidator::load();
1070        assert!(validator.is_some(), "should load from canonical path");
1071    }
1072
1073    #[test]
1074    fn composition_validator_query() {
1075        let validator = CompositionValidator::load().unwrap();
1076        let cell = validator.query("http", "listener", "tcp");
1077        assert!(cell.is_some());
1078        assert_eq!(cell.unwrap().tier, "drop_in");
1079    }
1080
1081    #[test]
1082    fn composition_validator_is_supported() {
1083        let validator = CompositionValidator::load().unwrap();
1084        assert!(validator.is_supported("http", "listener", "tcp"));
1085        assert!(validator.is_supported("socks5", "listener", "tcp"));
1086        assert!(validator.is_supported("socks5", "upstream", "udp"));
1087        assert!(!validator.is_supported("ssh", "listener", "tcp"));
1088        assert!(!validator.is_supported("quic", "listener", "tcp"));
1089    }
1090
1091    #[test]
1092    fn composition_validator_protocols_at_or_above_tier() {
1093        let validator = CompositionValidator::load().unwrap();
1094        let protos = validator.protocols_at_or_above_tier("listener", "tcp", "drop_in");
1095        assert!(protos.contains(&"http"));
1096        assert!(protos.contains(&"socks5"));
1097        assert!(!protos.contains(&"ssh"));
1098    }
1099
1100    #[test]
1101    fn composition_validator_constraints_for() {
1102        let validator = CompositionValidator::load().unwrap();
1103        let constraints = validator.constraints_for("http");
1104        assert!(!constraints.is_empty());
1105    }
1106
1107    /// Table-driven: every drop_in cell should have non-empty capability_ids
1108    /// (except reverse proxy cells which have not yet been added to the manifest)
1109    #[test]
1110    fn drop_in_cells_have_capability_ids() {
1111        let validator = CompositionValidator::load().unwrap();
1112        for cell in &validator.matrix.cell {
1113            if cell.tier == "drop_in"
1114                && cell.role != "reverse_server"
1115                && cell.role != "reverse_client"
1116            {
1117                assert!(
1118                    !cell.capability_ids.is_empty(),
1119                    "drop_in cell {}/{} has empty capability_ids",
1120                    cell.protocol,
1121                    cell.role
1122                );
1123            }
1124        }
1125    }
1126
1127    /// Table-driven: every intentional_non_parity cell should have rationale
1128    #[test]
1129    fn intentional_non_parity_cells_have_rationale() {
1130        let validator = CompositionValidator::load().unwrap();
1131        for cell in &validator.matrix.cell {
1132            if cell.tier == "intentional_non_parity" {
1133                assert!(
1134                    !cell.rationale.is_empty(),
1135                    "intentional_non_parity cell {}/{} has no rationale",
1136                    cell.protocol,
1137                    cell.role
1138                );
1139            }
1140        }
1141    }
1142
1143    /// Table-driven: every protocol-crate-only cell has tier != drop_in
1144    #[test]
1145    fn protocol_crate_only_not_drop_in() {
1146        let validator = CompositionValidator::load().unwrap();
1147        for cell in &validator.matrix.cell {
1148            if cell.caveat_class == "protocol_crate_only" {
1149                assert_ne!(
1150                    cell.tier, "drop_in",
1151                    "protocol-crate-only cell {}/{} has tier=drop_in",
1152                    cell.protocol, cell.role
1153                );
1154            }
1155        }
1156    }
1157
1158    /// Table-driven: chain compositions reference valid protocols
1159    #[test]
1160    fn chain_protocols_are_valid() {
1161        let validator = CompositionValidator::load().unwrap();
1162        for chain in &validator.matrix.chain {
1163            assert!(
1164                ALLOWED_PROTOCOLS.contains(&chain.from_protocol.as_str()),
1165                "chain has unknown from_protocol: {}",
1166                chain.from_protocol
1167            );
1168            assert!(
1169                ALLOWED_PROTOCOLS.contains(&chain.to_protocol.as_str()),
1170                "chain has unknown to_protocol: {}",
1171                chain.to_protocol
1172            );
1173        }
1174    }
1175
1176    /// Table-driven: constraints reference valid protocols in applies_to
1177    #[test]
1178    fn constraint_applies_to_valid_protocols() {
1179        let validator = CompositionValidator::load().unwrap();
1180        for constraint in &validator.matrix.constraint {
1181            for proto in &constraint.applies_to {
1182                assert!(
1183                    ALLOWED_PROTOCOLS.contains(&proto.as_str()),
1184                    "constraint type={} applies_to unknown protocol: {}",
1185                    constraint.constraint_type,
1186                    proto
1187                );
1188            }
1189        }
1190    }
1191
1192    /// Table-driven: every drop_in upstream cell has UDP or TCP capability
1193    #[test]
1194    fn drop_in_upstream_cells_have_traffic_kind() {
1195        let validator = CompositionValidator::load().unwrap();
1196        for cell in &validator.matrix.cell {
1197            if cell.role == "upstream" && cell.tier == "drop_in" {
1198                assert!(
1199                    cell.traffic_kind == "tcp" || cell.traffic_kind == "udp",
1200                    "drop_in upstream cell {}/{} has unexpected traffic_kind: {}",
1201                    cell.protocol,
1202                    cell.role,
1203                    cell.traffic_kind
1204                );
1205            }
1206        }
1207    }
1208
1209    /// Table-driven: listener cells are TCP (except socks5 and shadowsocks UDP ASSOCIATE)
1210    #[test]
1211    fn listener_cells_are_tcp_or_udp_associate() {
1212        let validator = CompositionValidator::load().unwrap();
1213        // Protocols that legitimately have UDP listener cells
1214        let udp_listener_protos = ["socks5", "shadowsocks"];
1215        for cell in &validator.matrix.cell {
1216            if cell.role == "listener" {
1217                if udp_listener_protos.contains(&cell.protocol.as_str())
1218                    && cell.traffic_kind == "udp"
1219                {
1220                    continue;
1221                }
1222                assert_eq!(
1223                    cell.traffic_kind, "tcp",
1224                    "listener cell {}/{} has traffic_kind={} (expected tcp)",
1225                    cell.protocol, cell.role, cell.traffic_kind
1226                );
1227            }
1228        }
1229    }
1230}