1use std::collections::HashSet;
12use std::fs;
13use std::path::{Path, PathBuf};
14
15use serde::Deserialize;
16use thiserror::Error;
17
18pub 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
44pub const ALLOWED_ROLES: &[&str] = &[
46 "listener",
47 "upstream",
48 "chain_hop",
49 "terminal",
50 "reverse_server",
51 "reverse_client",
52];
53
54pub const ALLOWED_TRAFFIC_KINDS: &[&str] = &["tcp", "udp"];
56
57pub const ALLOWED_TIERS: &[&str] = &[
59 "drop_in",
60 "compatible_with_warning",
61 "native_equivalent",
62 "intentional_non_parity",
63 "unsupported",
64];
65
66pub const ALLOWED_EVIDENCE: &[&str] = &[
68 "differential",
69 "integration",
70 "unit",
71 "synthetic",
72 "docs_only",
73 "none",
74];
75
76pub 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
87pub 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
99pub const PINNED_SCHEMA_VERSION: &str = "1";
101
102#[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#[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 pub chain_max: Option<u32>,
133}
134
135#[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#[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#[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#[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#[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
288pub fn validate_composition_matrix(
296 matrix: &CompositionMatrix,
297 manifest_capability_ids: &HashSet<&str>,
298) -> Result<(), CompositionValidationResult> {
299 let mut result = CompositionValidationResult::default();
300
301 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 if matrix.cell.is_empty() && matrix.chain.is_empty() {
311 result.push_error(CompositionValidationError::EmptyMatrix);
312 }
313
314 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 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 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
476pub 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
503pub 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
521pub 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
534pub 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
548pub 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
562pub 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
578pub 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
587pub 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
598pub struct CompositionValidator {
608 matrix: CompositionMatrix,
609}
610
611impl CompositionValidator {
612 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 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 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 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 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 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 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 pub fn inner(&self) -> &CompositionMatrix {
693 &self.matrix
694 }
695}
696
697#[cfg(test)]
698mod tests {
699 use super::*;
700
701 fn manifest_path_from_matrix(matrix_path: &Path) -> PathBuf {
703 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 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 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, };
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 #[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 #[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 #[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 #[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 #[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 #[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 #[test]
1211 fn listener_cells_are_tcp_or_udp_associate() {
1212 let validator = CompositionValidator::load().unwrap();
1213 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}