Skip to main content

eggress_testkit/
corpus.rs

1//! Validation helpers for the pproxy URI corpus and CLI fixture files.
2//!
3//! `tests/compat/fixtures/pproxy_uri_corpus.toml` is the canonical pproxy URI
4//! input corpus. Each case must have all required fields so downstream tests
5//! can rely on the corpus structure.
6//!
7//! `tests/compat/fixtures/pproxy_cli_cases/*.toml` are CLI translation
8//! fixtures validated by [`validate_cli_cases`].
9//!
10//! Tier taxonomy (from `docs/PARITY_MATRIX.md`):
11//! - `compatible` — eggress behavior matches pproxy for tested scenarios
12//! - `supported` — eggress supports the feature, pproxy equivalence not claimed
13//! - `partial` — usable subset exists but not full compatibility
14//! - `intentional_non_parity` — deliberately not replicated with rationale
15//! - `unsupported` — not implemented
16
17use std::collections::HashSet;
18use std::path::Path;
19
20const REQUIRED_FIELDS: &[&str] = &[
21    "id",
22    "raw_uri",
23    "pproxy_interpretation",
24    "expected_interpretation",
25    "compatibility_tier",
26    "has_credentials",
27    "expected_redacted_display",
28    "expected_warnings",
29];
30
31const VALID_TIERS: &[&str] = &[
32    "compatible",
33    "supported",
34    "partial",
35    "intentional_non_parity",
36    "unsupported",
37];
38
39const CLI_REQUIRED_FIELDS: &[&str] = &[
40    "id",
41    "args",
42    "expected_exit_code",
43    "expected_warnings",
44    "toml_content_must_contain",
45];
46
47const PYTHON_API_REQUIRED_FIELDS: &[&str] = &[
48    "id",
49    "category",
50    "description",
51    "pproxy_behavior",
52    "egress_behavior",
53    "tier",
54];
55
56const PYTHON_API_VALID_TIERS: &[&str] = &["A", "B", "C", "D", "N/A", "A ", "B ", "C ", "D "];
57
58/// Validation error from a corpus case check.
59#[derive(Debug, thiserror::Error)]
60pub enum CorpusValidationError {
61    #[error("failed to read file {path}: {error}")]
62    FileRead { path: String, error: std::io::Error },
63    #[error("failed to parse TOML in {path}: {error}")]
64    TomlParse { path: String, error: String },
65    #[error("case '{case_id}' in {path} is missing required field '{field}'")]
66    MissingField {
67        case_id: String,
68        path: String,
69        field: &'static str,
70    },
71    #[error(
72        "case '{case_id}' in {path} has invalid compatibility_tier '{tier}'; valid: {valid:?}"
73    )]
74    InvalidTier {
75        case_id: String,
76        path: String,
77        tier: String,
78        valid: Vec<&'static str>,
79    },
80    #[error("case '{case_id}' in {path} has non-string id")]
81    NonStringId { case_id: String, path: String },
82    #[error("corpus has duplicate case id '{id}' in {path}")]
83    DuplicateId { id: String, path: String },
84    #[error("{path} has zero cases")]
85    Empty { path: String },
86    #[error("case '{case_id}' in {path} has_credentials=true but expected_redacted_display does not contain '****'")]
87    MissingRedaction { case_id: String, path: String },
88    #[error("case '{case_id}' in {path} has expected_toml but it is empty")]
89    EmptyToml { case_id: String, path: String },
90    #[error("case '{case_id}' in {path} has unsupported/intentional_non_parity tier but no manifest feature maps to it")]
91    UnmappedFeature { case_id: String, path: String },
92}
93
94/// Load a TOML file and parse it as a `toml::Value`.
95fn load_toml(path: &Path) -> Result<toml::Value, CorpusValidationError> {
96    let path_str = path.display().to_string();
97    let content = std::fs::read_to_string(path).map_err(|e| CorpusValidationError::FileRead {
98        path: path_str.clone(),
99        error: e,
100    })?;
101    toml::from_str(&content).map_err(|e| CorpusValidationError::TomlParse {
102        path: path_str,
103        error: e.to_string(),
104    })
105}
106
107/// Load all manifest feature IDs from the canonical pproxy manifest.
108fn load_manifest_feature_ids(workspace_root: &Path) -> HashSet<String> {
109    let manifest_path = workspace_root.join("docs/parity/pproxy_capability_manifest.toml");
110    let Ok(value) = load_toml(&manifest_path) else {
111        return HashSet::new();
112    };
113    let mut ids = HashSet::new();
114    if let Some(capabilities) = value.get("capability").and_then(|v| v.as_array()) {
115        for cap in capabilities {
116            if let Some(id) = cap.get("id").and_then(|v| v.as_str()) {
117                ids.insert(id.to_string());
118            }
119        }
120    }
121    ids
122}
123
124/// Extract the raw_uri scheme (portion before `://`).
125fn uri_scheme(raw_uri: &str) -> Option<&str> {
126    raw_uri.find("://").map(|i| &raw_uri[..i])
127}
128
129/// Validate the corpus file at the given path.
130///
131/// Returns the number of cases validated on success.
132pub fn validate_uri_corpus(path: &Path) -> Result<usize, CorpusValidationError> {
133    let path_str = path.display().to_string();
134    let value = load_toml(path)?;
135
136    let cases =
137        value
138            .get("cases")
139            .and_then(|v| v.as_array())
140            .ok_or(CorpusValidationError::Empty {
141                path: path_str.clone(),
142            })?;
143
144    if cases.is_empty() {
145        return Err(CorpusValidationError::Empty {
146            path: path_str.clone(),
147        });
148    }
149
150    let valid_tiers: Vec<&'static str> = VALID_TIERS.to_vec();
151    let mut seen_ids = HashSet::new();
152
153    for case in cases {
154        let raw_id = case
155            .get("id")
156            .and_then(|v| v.as_str())
157            .unwrap_or("<missing>");
158        let case_id = raw_id.to_string();
159
160        for &field in REQUIRED_FIELDS {
161            if case.get(field).is_none() {
162                return Err(CorpusValidationError::MissingField {
163                    case_id,
164                    path: path_str.clone(),
165                    field,
166                });
167            }
168        }
169
170        // Verify compatibility_tier value
171        let tier = case
172            .get("compatibility_tier")
173            .and_then(|v| v.as_str())
174            .unwrap_or("");
175        if !valid_tiers.contains(&tier) {
176            return Err(CorpusValidationError::InvalidTier {
177                case_id,
178                path: path_str.clone(),
179                tier: tier.to_string(),
180                valid: valid_tiers.clone(),
181            });
182        }
183
184        // Verify id is unique
185        if !seen_ids.insert(case_id.clone()) {
186            return Err(CorpusValidationError::DuplicateId {
187                id: case_id,
188                path: path_str.clone(),
189            });
190        }
191
192        // Verify expected_warnings is an array
193        if case
194            .get("expected_warnings")
195            .and_then(|v| v.as_array())
196            .is_none()
197        {
198            return Err(CorpusValidationError::MissingField {
199                case_id,
200                path: path_str.clone(),
201                field: "expected_warnings (must be array)",
202            });
203        }
204
205        // When has_credentials is true, expected_redacted_display must contain ****
206        let has_creds = case
207            .get("has_credentials")
208            .and_then(|v| v.as_bool())
209            .unwrap_or(false);
210        if has_creds {
211            let display = case
212                .get("expected_redacted_display")
213                .and_then(|v| v.as_str())
214                .unwrap_or("");
215            if !display.contains("****") {
216                return Err(CorpusValidationError::MissingRedaction {
217                    case_id,
218                    path: path_str.clone(),
219                });
220            }
221        }
222
223        // When expected_toml is present, it must not be empty
224        if let Some(toml_val) = case.get("expected_toml") {
225            if toml_val
226                .as_str()
227                .map(|s| s.trim().is_empty())
228                .unwrap_or(true)
229            {
230                return Err(CorpusValidationError::EmptyToml {
231                    case_id,
232                    path: path_str.clone(),
233                });
234            }
235        }
236    }
237
238    Ok(cases.len())
239}
240
241/// Validate the corpus file at the canonical location relative to the
242/// workspace root.
243pub fn validate_workspace_uri_corpus() -> Result<usize, CorpusValidationError> {
244    let workspace_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
245        .join("..")
246        .join("..");
247    let path = workspace_root.join("tests/compat/fixtures/pproxy_uri_corpus.toml");
248    validate_uri_corpus(&path)
249}
250
251/// Validate every `pproxy_cli_cases/*.toml` fixture has the required schema.
252///
253/// Returns the number of fixtures validated on success.
254pub fn validate_cli_cases(workspace_root: &Path) -> Result<usize, CorpusValidationError> {
255    let cli_dir = workspace_root.join("tests/compat/fixtures/pproxy_cli_cases");
256    let path_str = cli_dir.display().to_string();
257
258    let mut entries: Vec<_> = std::fs::read_dir(&cli_dir)
259        .map_err(|e| CorpusValidationError::FileRead {
260            path: path_str.clone(),
261            error: e,
262        })?
263        .filter_map(|e| e.ok())
264        .filter(|e| e.path().extension().is_some_and(|ext| ext == "toml"))
265        .collect();
266    entries.sort_by_key(|e| e.path());
267
268    if entries.is_empty() {
269        return Err(CorpusValidationError::Empty { path: path_str });
270    }
271
272    let mut seen_ids = HashSet::new();
273
274    for entry in &entries {
275        let path = entry.path();
276        let path_str = path.display().to_string();
277        let value = load_toml(&path)?;
278
279        let raw_id = value
280            .get("id")
281            .and_then(|v| v.as_str())
282            .unwrap_or("<missing>");
283        let case_id = raw_id.to_string();
284
285        for &field in CLI_REQUIRED_FIELDS {
286            if value.get(field).is_none() {
287                return Err(CorpusValidationError::MissingField {
288                    case_id,
289                    path: path_str,
290                    field,
291                });
292            }
293        }
294
295        // Verify args is an array
296        if value.get("args").and_then(|v| v.as_array()).is_none() {
297            return Err(CorpusValidationError::MissingField {
298                case_id,
299                path: path_str,
300                field: "args (must be array)",
301            });
302        }
303
304        // Verify expected_exit_code is an integer
305        if value
306            .get("expected_exit_code")
307            .and_then(|v| v.as_integer())
308            .is_none()
309        {
310            return Err(CorpusValidationError::MissingField {
311                case_id,
312                path: path_str,
313                field: "expected_exit_code (must be integer)",
314            });
315        }
316
317        // Verify expected_warnings is an array
318        if value
319            .get("expected_warnings")
320            .and_then(|v| v.as_array())
321            .is_none()
322        {
323            return Err(CorpusValidationError::MissingField {
324                case_id,
325                path: path_str,
326                field: "expected_warnings (must be array)",
327            });
328        }
329
330        // Verify toml_content_must_contain is an array
331        if value
332            .get("toml_content_must_contain")
333            .and_then(|v| v.as_array())
334            .is_none()
335        {
336            return Err(CorpusValidationError::MissingField {
337                case_id,
338                path: path_str,
339                field: "toml_content_must_contain (must be array)",
340            });
341        }
342
343        // Verify id is unique
344        if !seen_ids.insert(case_id.clone()) {
345            return Err(CorpusValidationError::DuplicateId {
346                id: case_id,
347                path: path_str,
348            });
349        }
350    }
351
352    Ok(entries.len())
353}
354
355/// Validate the CLI cases at the canonical workspace location.
356pub fn validate_workspace_cli_cases() -> Result<usize, CorpusValidationError> {
357    let workspace_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
358        .join("..")
359        .join("..");
360    validate_cli_cases(&workspace_root)
361}
362
363/// Validate corpus feature-to-manifest mapping.
364///
365/// For every corpus case with `unsupported` or `intentional_non_parity` tier,
366/// verify that the manifest has a corresponding feature ID based on the URI
367/// scheme.
368pub fn validate_corpus_manifest_mapping(path: &Path) -> Result<usize, CorpusValidationError> {
369    let workspace_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
370        .join("..")
371        .join("..");
372    let manifest_ids = load_manifest_feature_ids(&workspace_root);
373    let path_str = path.display().to_string();
374    let value = load_toml(path)?;
375
376    let cases =
377        value
378            .get("cases")
379            .and_then(|v| v.as_array())
380            .ok_or(CorpusValidationError::Empty {
381                path: path_str.clone(),
382            })?;
383
384    let mut checked = 0;
385    for case in cases {
386        let raw_id = case
387            .get("id")
388            .and_then(|v| v.as_str())
389            .unwrap_or("<missing>");
390        let case_id = raw_id.to_string();
391        let tier = case
392            .get("compatibility_tier")
393            .and_then(|v| v.as_str())
394            .unwrap_or("");
395
396        // Only check unsupported/intentional_non_parity cases for manifest mapping
397        if tier != "unsupported" && tier != "intentional_non_parity" {
398            continue;
399        }
400
401        let raw_uri = case.get("raw_uri").and_then(|v| v.as_str()).unwrap_or("");
402        let scheme = uri_scheme(raw_uri).unwrap_or("");
403
404        // Map well-known schemes to expected manifest feature IDs.
405        // Only check schemes that clearly correspond to manifest features.
406        // Edge-case URIs (invalid syntax, scheme+transport combos like socks5+in+ssl)
407        // are intentionally excluded since they test parser rejection, not feature parity.
408        let expected_features: Vec<&str> = match scheme {
409            "h2" => vec!["uri.scheme_h2"],
410            "ws" | "wss" => vec!["uri.scheme_ws"],
411            "raw" => vec!["uri.scheme_raw"],
412            "quic" | "h3" => vec![],
413            "ssr" => vec!["uri.scheme_ssr", "protocol.ssr"],
414            "ssh" => vec!["uri.scheme_ssh"],
415            "ftp" => vec![],
416            _ => vec![],
417        };
418
419        if !expected_features.is_empty() {
420            let has_mapping = expected_features.iter().any(|f| manifest_ids.contains(*f));
421            if !has_mapping {
422                return Err(CorpusValidationError::UnmappedFeature {
423                    case_id,
424                    path: path_str.clone(),
425                });
426            }
427        }
428
429        checked += 1;
430    }
431
432    Ok(checked)
433}
434
435/// Run the full workspace corpus validation suite:
436/// 1. URI corpus schema validation
437/// 2. CLI cases schema validation
438/// 3. Corpus-to-manifest feature mapping
439/// 4. Python API cases schema validation
440///
441/// Returns `(corpus_cases, cli_cases, manifest_mapped, python_api_cases)` on success.
442pub fn validate_workspace_corpus_full(
443) -> Result<(usize, usize, usize, usize), CorpusValidationError> {
444    let corpus = validate_workspace_uri_corpus()?;
445    let cli = validate_workspace_cli_cases()?;
446    let workspace_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
447        .join("..")
448        .join("..");
449    let corpus_path = workspace_root.join("tests/compat/fixtures/pproxy_uri_corpus.toml");
450    let mapped = validate_corpus_manifest_mapping(&corpus_path)?;
451    let python_api = validate_workspace_python_api_cases()?;
452    Ok((corpus, cli, mapped, python_api))
453}
454
455/// Validate the Python API cases fixture at the given path.
456///
457/// Each case must have the legacy schema fields: `id`, `category`,
458/// `description`, `pproxy_behavior`, `egress_behavior`, `tier`. Optional
459/// fields include `notes`. The `tier` value must be one of A/B/C/D/N/A
460/// (or a trailing-whitespace variant tolerated for legacy data).
461///
462/// Returns the number of cases validated on success.
463pub fn validate_python_api_cases(path: &Path) -> Result<usize, CorpusValidationError> {
464    let path_str = path.display().to_string();
465    let value = load_toml(path)?;
466
467    // python_api_cases.toml uses array-of-tables ([[case]]), not an inline
468    // `cases = [...]` array. Pull both forms to support either convention.
469    let cases: Vec<toml::Value> = value
470        .get("cases")
471        .or_else(|| value.get("case"))
472        .and_then(|v| v.as_array())
473        .cloned()
474        .ok_or_else(|| CorpusValidationError::Empty {
475            path: path_str.clone(),
476        })?;
477
478    if cases.is_empty() {
479        return Err(CorpusValidationError::Empty {
480            path: path_str.clone(),
481        });
482    }
483
484    let valid_tiers: Vec<&'static str> = PYTHON_API_VALID_TIERS.to_vec();
485    let mut seen_ids = HashSet::new();
486
487    for case in &cases {
488        let raw_id = case
489            .get("id")
490            .and_then(|v| v.as_str())
491            .unwrap_or("<missing>");
492        let case_id = raw_id.to_string();
493
494        for &field in PYTHON_API_REQUIRED_FIELDS {
495            if case.get(field).is_none() {
496                return Err(CorpusValidationError::MissingField {
497                    case_id,
498                    path: path_str.clone(),
499                    field,
500                });
501            }
502        }
503
504        let tier = case.get("tier").and_then(|v| v.as_str()).unwrap_or("");
505        if !valid_tiers.contains(&tier) {
506            return Err(CorpusValidationError::InvalidTier {
507                case_id,
508                path: path_str.clone(),
509                tier: tier.to_string(),
510                valid: vec!["A", "B", "C", "D", "N/A"],
511            });
512        }
513
514        if !seen_ids.insert(case_id.clone()) {
515            return Err(CorpusValidationError::DuplicateId {
516                id: case_id,
517                path: path_str.clone(),
518            });
519        }
520    }
521
522    Ok(cases.len())
523}
524
525/// Validate the Python API cases at the canonical workspace location.
526pub fn validate_workspace_python_api_cases() -> Result<usize, CorpusValidationError> {
527    let workspace_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
528        .join("..")
529        .join("..");
530    let path = workspace_root.join("tests/compat/fixtures/python_api_cases.toml");
531    validate_python_api_cases(&path)
532}
533
534#[cfg(test)]
535mod tests {
536    use super::*;
537
538    #[test]
539    fn workspace_uri_corpus_is_valid() {
540        let n = validate_workspace_uri_corpus().expect("pproxy_uri_corpus.toml should validate");
541        assert!(n > 0, "corpus must have at least one case");
542        assert!(n >= 50, "corpus should have at least 50 cases, got {n}");
543    }
544
545    #[test]
546    fn workspace_cli_cases_are_valid() {
547        let n = validate_workspace_cli_cases().expect("cli_cases should validate");
548        assert!(n > 0, "cli_cases must have at least one fixture");
549    }
550
551    #[test]
552    fn corpus_manifest_mapping_is_valid() {
553        let workspace_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
554            .join("..")
555            .join("..");
556        let corpus_path = workspace_root.join("tests/compat/fixtures/pproxy_uri_corpus.toml");
557        let n = validate_corpus_manifest_mapping(&corpus_path)
558            .expect("corpus-to-manifest mapping should validate");
559        assert!(
560            n > 0,
561            "should have at least one unsupported/intentional_non_parity case"
562        );
563    }
564
565    #[test]
566    fn full_corpus_validation() {
567        let (corpus, cli, mapped, python_api) =
568            validate_workspace_corpus_full().expect("full corpus validation should pass");
569        assert!(corpus >= 50);
570        assert!(cli >= 1);
571        assert!(mapped >= 1);
572        assert!(
573            python_api >= 50,
574            "python_api cases should have at least 50 cases, got {python_api}"
575        );
576    }
577
578    #[test]
579    fn workspace_python_api_cases_are_valid() {
580        let n =
581            validate_workspace_python_api_cases().expect("python_api_cases.toml should validate");
582        assert!(
583            n >= 50,
584            "python_api cases should have at least 50 cases, got {n}"
585        );
586    }
587}