Skip to main content

eggress_testkit/oracle/
scenario.rs

1//! Oracle scenario definitions and registry.
2//!
3//! Each scenario describes an equivalent pproxy/eggress configuration pair
4//! and a client action to exercise. The runner executes both sides and
5//! compares normalized outputs.
6
7use std::time::Duration;
8
9use serde::{Deserialize, Serialize};
10
11/// Target equivalence class for comparison.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(rename_all = "snake_case")]
14pub enum EquivalenceTarget {
15    /// Both should produce identical output bytes.
16    Payload,
17    /// Both should succeed or both should fail (exact error may differ).
18    CoarseResult,
19    /// Both should expose the same port/protocol binding.
20    BindAddress,
21    /// Both should produce the same HTTP status code.
22    StatusCode,
23}
24
25/// Platform requirements for a scenario.
26#[derive(Debug, Clone, Default)]
27pub struct PlatformRequirements {
28    pub requires_root: bool,
29    pub requires_ipv6: bool,
30    pub requires_python_package: Option<String>,
31    pub required_os: Option<&'static str>,
32}
33
34/// Normalization rules applied before comparison.
35#[derive(Debug, Clone, Default)]
36pub struct NormalizationRules {
37    /// Strip pproxy-specific log prefixes from stderr.
38    pub strip_log_prefixes: bool,
39    /// Normalize port numbers (replace dynamic ports with placeholder).
40    pub normalize_ports: bool,
41    /// Normalize line endings.
42    pub normalize_line_endings: bool,
43    /// Strip version strings.
44    pub strip_versions: bool,
45}
46
47/// A single oracle scenario definition.
48#[derive(Debug, Clone)]
49pub struct OracleScenario {
50    /// Unique scenario identifier.
51    pub id: &'static str,
52    /// Capability IDs this scenario exercises.
53    pub capability_ids: Vec<&'static str>,
54    /// Human-readable description.
55    pub description: &'static str,
56    /// pproxy CLI arguments (excluding `-m pproxy`).
57    pub pproxy_args: Vec<&'static str>,
58    /// eggress TOML configuration (with `{PORT}` and `{ECHO_PORT}` placeholders).
59    pub eggress_toml: &'static str,
60    /// Expected equivalence target.
61    pub expected_equivalence: EquivalenceTarget,
62    /// Normalization rules for this scenario.
63    pub normalization: NormalizationRules,
64    /// Platform requirements.
65    pub platform: PlatformRequirements,
66    /// Maximum time for the full scenario execution.
67    pub timeout: Duration,
68    /// Scenario category for grouping.
69    pub category: ScenarioCategory,
70}
71
72/// Scenario categories for grouping and filtering.
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
74#[serde(rename_all = "snake_case")]
75pub enum ScenarioCategory {
76    /// CLI defaults and basic protocol listeners.
77    CliDefaults,
78    /// HTTP and SOCKS TCP connect scenarios.
79    HttpSocksTcp,
80    /// Proxy chaining scenarios.
81    Chains,
82    /// Rule-based routing scenarios.
83    Rules,
84    /// UDP relay scenarios.
85    Udp,
86}
87
88/// Get all registered oracle scenarios.
89pub fn all_scenarios() -> Vec<OracleScenario> {
90    let mut scenarios = Vec::new();
91    scenarios.extend(cli_defaults_scenarios());
92    scenarios.extend(http_socks_tcp_scenarios());
93    scenarios.extend(chain_scenarios());
94    scenarios.extend(rule_scenarios());
95    scenarios.extend(udp_scenarios());
96    scenarios
97}
98
99/// Get scenarios by category.
100pub fn scenarios_for_category(category: ScenarioCategory) -> Vec<OracleScenario> {
101    all_scenarios()
102        .into_iter()
103        .filter(|s| s.category == category)
104        .collect()
105}
106
107/// Get a scenario by ID.
108pub fn find_scenario(id: &str) -> Option<OracleScenario> {
109    all_scenarios().into_iter().find(|s| s.id == id)
110}
111
112// ===== CLI/Defaults (7 scenarios) =====
113
114fn cli_defaults_scenarios() -> Vec<OracleScenario> {
115    vec![
116        OracleScenario {
117            id: "cli.socks5_default",
118            capability_ids: vec!["cli.socks5_default", "uri.socks5"],
119            description: "SOCKS5 listener on default port with direct routing",
120            pproxy_args: vec!["-l", "socks5://127.0.0.1:{PORT}", "-r", "direct"],
121            eggress_toml: r#"
122version = 1
123
124[[listeners]]
125name = "test"
126bind = "127.0.0.1:{PORT}"
127protocols = ["socks5"]
128"#,
129            expected_equivalence: EquivalenceTarget::CoarseResult,
130            normalization: NormalizationRules {
131                strip_log_prefixes: true,
132                normalize_ports: true,
133                ..Default::default()
134            },
135            platform: PlatformRequirements::default(),
136            timeout: Duration::from_secs(10),
137            category: ScenarioCategory::CliDefaults,
138        },
139        OracleScenario {
140            id: "cli.socks4_default",
141            capability_ids: vec!["cli.socks4_default", "uri.socks4"],
142            description: "SOCKS4 listener on default port with direct routing",
143            pproxy_args: vec!["-l", "socks4://127.0.0.1:{PORT}", "-r", "direct"],
144            eggress_toml: r#"
145version = 1
146
147[[listeners]]
148name = "test"
149bind = "127.0.0.1:{PORT}"
150protocols = ["socks4"]
151"#,
152            expected_equivalence: EquivalenceTarget::CoarseResult,
153            normalization: NormalizationRules {
154                strip_log_prefixes: true,
155                normalize_ports: true,
156                ..Default::default()
157            },
158            platform: PlatformRequirements::default(),
159            timeout: Duration::from_secs(10),
160            category: ScenarioCategory::CliDefaults,
161        },
162        OracleScenario {
163            id: "cli.http_default",
164            capability_ids: vec!["cli.http_default", "uri.http"],
165            description: "HTTP CONNECT listener on default port with direct routing",
166            pproxy_args: vec!["-l", "http://127.0.0.1:{PORT}", "-r", "direct"],
167            eggress_toml: r#"
168version = 1
169
170[[listeners]]
171name = "test"
172bind = "127.0.0.1:{PORT}"
173protocols = ["http"]
174"#,
175            expected_equivalence: EquivalenceTarget::CoarseResult,
176            normalization: NormalizationRules {
177                strip_log_prefixes: true,
178                normalize_ports: true,
179                ..Default::default()
180            },
181            platform: PlatformRequirements::default(),
182            timeout: Duration::from_secs(10),
183            category: ScenarioCategory::CliDefaults,
184        },
185        OracleScenario {
186            id: "cli.https_default",
187            capability_ids: vec!["cli.https_default", "uri.https"],
188            description: "HTTPS (TLS) CONNECT listener with direct routing",
189            pproxy_args: vec!["-l", "https://127.0.0.1:{PORT}", "-r", "direct"],
190            eggress_toml: r#"
191version = 1
192
193[[listeners]]
194name = "test"
195bind = "127.0.0.1:{PORT}"
196protocols = ["http"]
197
198[listeners.tls]
199cert = "tests/fixtures/cert.pem"
200key = "tests/fixtures/key.pem"
201"#,
202            expected_equivalence: EquivalenceTarget::CoarseResult,
203            normalization: NormalizationRules {
204                strip_log_prefixes: true,
205                normalize_ports: true,
206                ..Default::default()
207            },
208            platform: PlatformRequirements::default(),
209            timeout: Duration::from_secs(10),
210            category: ScenarioCategory::CliDefaults,
211        },
212        OracleScenario {
213            id: "cli.ss_default",
214            capability_ids: vec!["cli.ss_default", "uri.ss"],
215            description: "Shadowsocks listener with direct routing",
216            pproxy_args: vec![
217                "-l",
218                "ss://127.0.0.1:{PORT}#testuser:testpass",
219                "-r",
220                "direct",
221            ],
222            eggress_toml: r#"
223version = 1
224
225[[listeners]]
226name = "test"
227bind = "127.0.0.1:{PORT}"
228protocols = ["shadowsocks"]
229
230[listeners.shadowsocks]
231password = "testpass"
232method = "aes-256-gcm"
233"#,
234            expected_equivalence: EquivalenceTarget::CoarseResult,
235            normalization: NormalizationRules {
236                strip_log_prefixes: true,
237                normalize_ports: true,
238                ..Default::default()
239            },
240            platform: PlatformRequirements::default(),
241            timeout: Duration::from_secs(10),
242            category: ScenarioCategory::CliDefaults,
243        },
244        OracleScenario {
245            id: "cli.trojan_default",
246            capability_ids: vec!["cli.trojan_default", "uri.trojan"],
247            description: "Trojan listener with direct routing",
248            pproxy_args: vec!["-l", "trojan://127.0.0.1:{PORT}", "-r", "direct"],
249            eggress_toml: r#"
250version = 1
251
252[[listeners]]
253name = "test"
254bind = "127.0.0.1:{PORT}"
255protocols = ["trojan"]
256
257[listeners.trojan]
258password = "password"
259
260[listeners.tls]
261cert = "tests/fixtures/cert.pem"
262key = "tests/fixtures/key.pem"
263"#,
264            expected_equivalence: EquivalenceTarget::CoarseResult,
265            normalization: NormalizationRules {
266                strip_log_prefixes: true,
267                normalize_ports: true,
268                ..Default::default()
269            },
270            platform: PlatformRequirements::default(),
271            timeout: Duration::from_secs(10),
272            category: ScenarioCategory::CliDefaults,
273        },
274        OracleScenario {
275            id: "cli.mixed_default",
276            capability_ids: vec!["cli.mixed_default"],
277            description: "Mixed SOCKS5+HTTP listener on single port",
278            pproxy_args: vec![
279                "-l",
280                "socks5://127.0.0.1:{PORT}",
281                "-l",
282                "http://127.0.0.1:{PORT2}",
283                "-r",
284                "direct",
285            ],
286            eggress_toml: r#"
287version = 1
288
289[[listeners]]
290name = "test"
291bind = "127.0.0.1:{PORT}"
292protocols = ["socks5", "http"]
293"#,
294            expected_equivalence: EquivalenceTarget::CoarseResult,
295            normalization: NormalizationRules {
296                strip_log_prefixes: true,
297                normalize_ports: true,
298                ..Default::default()
299            },
300            platform: PlatformRequirements::default(),
301            timeout: Duration::from_secs(10),
302            category: ScenarioCategory::CliDefaults,
303        },
304    ]
305}
306
307// ===== HTTP/SOCKS TCP (10 scenarios) =====
308
309fn http_socks_tcp_scenarios() -> Vec<OracleScenario> {
310    vec![
311        OracleScenario {
312            id: "tcp.http_connect",
313            capability_ids: vec!["protocol.http_connect"],
314            description: "HTTP CONNECT to TCP echo server",
315            pproxy_args: vec!["-l", "http://127.0.0.1:{PORT}", "-r", "direct"],
316            eggress_toml: r#"
317version = 1
318
319[[listeners]]
320name = "test"
321bind = "127.0.0.1:{PORT}"
322protocols = ["http"]
323"#,
324            expected_equivalence: EquivalenceTarget::Payload,
325            normalization: NormalizationRules {
326                strip_log_prefixes: true,
327                normalize_ports: true,
328                ..Default::default()
329            },
330            platform: PlatformRequirements::default(),
331            timeout: Duration::from_secs(10),
332            category: ScenarioCategory::HttpSocksTcp,
333        },
334        OracleScenario {
335            id: "tcp.socks4_connect",
336            capability_ids: vec!["protocol.socks4_connect"],
337            description: "SOCKS4 CONNECT to TCP echo server",
338            pproxy_args: vec!["-l", "socks4://127.0.0.1:{PORT}", "-r", "direct"],
339            eggress_toml: r#"
340version = 1
341
342[[listeners]]
343name = "test"
344bind = "127.0.0.1:{PORT}"
345protocols = ["socks4"]
346"#,
347            expected_equivalence: EquivalenceTarget::Payload,
348            normalization: NormalizationRules {
349                strip_log_prefixes: true,
350                normalize_ports: true,
351                ..Default::default()
352            },
353            platform: PlatformRequirements::default(),
354            timeout: Duration::from_secs(10),
355            category: ScenarioCategory::HttpSocksTcp,
356        },
357        OracleScenario {
358            id: "tcp.socks4a_connect",
359            capability_ids: vec!["protocol.socks4a_connect"],
360            description: "SOCKS4a CONNECT (domain name) to TCP echo server",
361            pproxy_args: vec!["-l", "socks4a://127.0.0.1:{PORT}", "-r", "direct"],
362            eggress_toml: r#"
363version = 1
364
365[[listeners]]
366name = "test"
367bind = "127.0.0.1:{PORT}"
368protocols = ["socks4"]
369"#,
370            expected_equivalence: EquivalenceTarget::Payload,
371            normalization: NormalizationRules {
372                strip_log_prefixes: true,
373                normalize_ports: true,
374                ..Default::default()
375            },
376            platform: PlatformRequirements::default(),
377            timeout: Duration::from_secs(10),
378            category: ScenarioCategory::HttpSocksTcp,
379        },
380        OracleScenario {
381            id: "tcp.socks5_connect",
382            capability_ids: vec!["protocol.socks5_connect"],
383            description: "SOCKS5 CONNECT to TCP echo server",
384            pproxy_args: vec!["-l", "socks5://127.0.0.1:{PORT}", "-r", "direct"],
385            eggress_toml: r#"
386version = 1
387
388[[listeners]]
389name = "test"
390bind = "127.0.0.1:{PORT}"
391protocols = ["socks5"]
392"#,
393            expected_equivalence: EquivalenceTarget::Payload,
394            normalization: NormalizationRules {
395                strip_log_prefixes: true,
396                normalize_ports: true,
397                ..Default::default()
398            },
399            platform: PlatformRequirements::default(),
400            timeout: Duration::from_secs(10),
401            category: ScenarioCategory::HttpSocksTcp,
402        },
403        OracleScenario {
404            id: "tcp.socks5_auth",
405            capability_ids: vec!["protocol.socks5_auth"],
406            description: "SOCKS5 CONNECT with username/password auth",
407            pproxy_args: vec!["-l", "socks5://127.0.0.1:{PORT}#user:pass", "-r", "direct"],
408            eggress_toml: r#"
409version = 1
410
411[[listeners]]
412name = "test"
413bind = "127.0.0.1:{PORT}"
414protocols = ["socks5"]
415
416[listeners.auth]
417type = "password"
418username = "user"
419password = "pass"
420"#,
421            expected_equivalence: EquivalenceTarget::Payload,
422            normalization: NormalizationRules {
423                strip_log_prefixes: true,
424                normalize_ports: true,
425                ..Default::default()
426            },
427            platform: PlatformRequirements::default(),
428            timeout: Duration::from_secs(10),
429            category: ScenarioCategory::HttpSocksTcp,
430        },
431        OracleScenario {
432            id: "tcp.socks5_connect_domain",
433            capability_ids: vec!["protocol.socks5_connect_domain"],
434            description: "SOCKS5 CONNECT via domain name target",
435            pproxy_args: vec!["-l", "socks5://127.0.0.1:{PORT}", "-r", "direct"],
436            eggress_toml: r#"
437version = 1
438
439[[listeners]]
440name = "test"
441bind = "127.0.0.1:{PORT}"
442protocols = ["socks5"]
443"#,
444            expected_equivalence: EquivalenceTarget::Payload,
445            normalization: NormalizationRules {
446                strip_log_prefixes: true,
447                normalize_ports: true,
448                ..Default::default()
449            },
450            platform: PlatformRequirements::default(),
451            timeout: Duration::from_secs(10),
452            category: ScenarioCategory::HttpSocksTcp,
453        },
454        OracleScenario {
455            id: "tcp.socks5_refused",
456            capability_ids: vec!["protocol.socks5_refused"],
457            description: "SOCKS5 CONNECT to refused port (negative case)",
458            pproxy_args: vec!["-l", "socks5://127.0.0.1:{PORT}", "-r", "direct"],
459            eggress_toml: r#"
460version = 1
461
462[[listeners]]
463name = "test"
464bind = "127.0.0.1:{PORT}"
465protocols = ["socks5"]
466"#,
467            expected_equivalence: EquivalenceTarget::CoarseResult,
468            normalization: NormalizationRules {
469                strip_log_prefixes: true,
470                normalize_ports: true,
471                ..Default::default()
472            },
473            platform: PlatformRequirements::default(),
474            timeout: Duration::from_secs(10),
475            category: ScenarioCategory::HttpSocksTcp,
476        },
477        OracleScenario {
478            id: "tcp.http_forward_get",
479            capability_ids: vec!["protocol.http_forward_get"],
480            description: "HTTP forward proxy GET request",
481            pproxy_args: vec!["-l", "http://127.0.0.1:{PORT}", "-r", "direct"],
482            eggress_toml: r#"
483version = 1
484
485[[listeners]]
486name = "test"
487bind = "127.0.0.1:{PORT}"
488protocols = ["http"]
489"#,
490            expected_equivalence: EquivalenceTarget::Payload,
491            normalization: NormalizationRules {
492                strip_log_prefixes: true,
493                normalize_ports: true,
494                normalize_line_endings: true,
495                ..Default::default()
496            },
497            platform: PlatformRequirements::default(),
498            timeout: Duration::from_secs(10),
499            category: ScenarioCategory::HttpSocksTcp,
500        },
501        OracleScenario {
502            id: "tcp.http_forward_post",
503            capability_ids: vec!["protocol.http_forward_post"],
504            description: "HTTP forward proxy POST request",
505            pproxy_args: vec!["-l", "http://127.0.0.1:{PORT}", "-r", "direct"],
506            eggress_toml: r#"
507version = 1
508
509[[listeners]]
510name = "test"
511bind = "127.0.0.1:{PORT}"
512protocols = ["http"]
513"#,
514            expected_equivalence: EquivalenceTarget::Payload,
515            normalization: NormalizationRules {
516                strip_log_prefixes: true,
517                normalize_ports: true,
518                normalize_line_endings: true,
519                ..Default::default()
520            },
521            platform: PlatformRequirements::default(),
522            timeout: Duration::from_secs(10),
523            category: ScenarioCategory::HttpSocksTcp,
524        },
525        OracleScenario {
526            id: "tcp.socks5_auth_failure",
527            capability_ids: vec!["protocol.socks5_auth_failure"],
528            description: "SOCKS5 with wrong credentials (negative case)",
529            pproxy_args: vec!["-l", "socks5://127.0.0.1:{PORT}#user:pass", "-r", "direct"],
530            eggress_toml: r#"
531version = 1
532
533[[listeners]]
534name = "test"
535bind = "127.0.0.1:{PORT}"
536protocols = ["socks5"]
537
538[listeners.auth]
539type = "password"
540username = "user"
541password = "pass"
542"#,
543            expected_equivalence: EquivalenceTarget::CoarseResult,
544            normalization: NormalizationRules {
545                strip_log_prefixes: true,
546                normalize_ports: true,
547                ..Default::default()
548            },
549            platform: PlatformRequirements::default(),
550            timeout: Duration::from_secs(10),
551            category: ScenarioCategory::HttpSocksTcp,
552        },
553    ]
554}
555
556// ===== Chains (5 scenarios) =====
557
558fn chain_scenarios() -> Vec<OracleScenario> {
559    vec![
560        OracleScenario {
561            id: "chain.socks5_to_socks5",
562            capability_ids: vec!["routing.chain_socks5_socks5"],
563            description: "SOCKS5 chained through another SOCKS5 upstream",
564            pproxy_args: vec![
565                "-l",
566                "socks5://127.0.0.1:{PORT}",
567                "-r",
568                "socks5://127.0.0.1:{UPSTREAM_PORT}",
569            ],
570            eggress_toml: r#"
571version = 1
572
573[[listeners]]
574name = "test"
575bind = "127.0.0.1:{PORT}"
576protocols = ["socks5"]
577
578[[upstreams]]
579id = "upstream-0"
580uri = "socks5://127.0.0.1:{UPSTREAM_PORT}"
581
582[[upstream_groups]]
583id = "chain-group"
584members = ["upstream-0"]
585
586[[rules]]
587id = "route-all"
588upstream_group = "chain-group"
589"#,
590            expected_equivalence: EquivalenceTarget::Payload,
591            normalization: NormalizationRules {
592                strip_log_prefixes: true,
593                normalize_ports: true,
594                ..Default::default()
595            },
596            platform: PlatformRequirements::default(),
597            timeout: Duration::from_secs(15),
598            category: ScenarioCategory::Chains,
599        },
600        OracleScenario {
601            id: "chain.http_to_socks5",
602            capability_ids: vec!["routing.chain_http_socks5"],
603            description: "HTTP CONNECT chained through SOCKS5 upstream",
604            pproxy_args: vec![
605                "-l",
606                "http://127.0.0.1:{PORT}",
607                "-r",
608                "socks5://127.0.0.1:{UPSTREAM_PORT}",
609            ],
610            eggress_toml: r#"
611version = 1
612
613[[listeners]]
614name = "test"
615bind = "127.0.0.1:{PORT}"
616protocols = ["http"]
617
618[[upstreams]]
619id = "upstream-0"
620uri = "socks5://127.0.0.1:{UPSTREAM_PORT}"
621
622[[upstream_groups]]
623id = "chain-group"
624members = ["upstream-0"]
625
626[[rules]]
627id = "route-all"
628upstream_group = "chain-group"
629"#,
630            expected_equivalence: EquivalenceTarget::Payload,
631            normalization: NormalizationRules {
632                strip_log_prefixes: true,
633                normalize_ports: true,
634                ..Default::default()
635            },
636            platform: PlatformRequirements::default(),
637            timeout: Duration::from_secs(15),
638            category: ScenarioCategory::Chains,
639        },
640        OracleScenario {
641            id: "chain.socks5_to_http",
642            capability_ids: vec!["routing.chain_socks5_http"],
643            description: "SOCKS5 chained through HTTP upstream",
644            pproxy_args: vec![
645                "-l",
646                "socks5://127.0.0.1:{PORT}",
647                "-r",
648                "http://127.0.0.1:{UPSTREAM_PORT}",
649            ],
650            eggress_toml: r#"
651version = 1
652
653[[listeners]]
654name = "test"
655bind = "127.0.0.1:{PORT}"
656protocols = ["socks5"]
657
658[[upstreams]]
659id = "upstream-0"
660uri = "http://127.0.0.1:{UPSTREAM_PORT}"
661
662[[upstream_groups]]
663id = "chain-group"
664members = ["upstream-0"]
665
666[[rules]]
667id = "route-all"
668upstream_group = "chain-group"
669"#,
670            expected_equivalence: EquivalenceTarget::Payload,
671            normalization: NormalizationRules {
672                strip_log_prefixes: true,
673                normalize_ports: true,
674                ..Default::default()
675            },
676            platform: PlatformRequirements::default(),
677            timeout: Duration::from_secs(15),
678            category: ScenarioCategory::Chains,
679        },
680        OracleScenario {
681            id: "chain.socks5_auth_to_socks5",
682            capability_ids: vec!["routing.chain_auth"],
683            description: "SOCKS5 with auth chained through SOCKS5 upstream",
684            pproxy_args: vec![
685                "-l",
686                "socks5://127.0.0.1:{PORT}#user:pass",
687                "-r",
688                "socks5://127.0.0.1:{UPSTREAM_PORT}",
689            ],
690            eggress_toml: r#"
691version = 1
692
693[[listeners]]
694name = "test"
695bind = "127.0.0.1:{PORT}"
696protocols = ["socks5"]
697
698[listeners.auth]
699type = "password"
700username = "user"
701password = "pass"
702
703[[upstreams]]
704id = "upstream-0"
705uri = "socks5://127.0.0.1:{UPSTREAM_PORT}"
706
707[[upstream_groups]]
708id = "chain-group"
709members = ["upstream-0"]
710
711[[rules]]
712id = "route-all"
713upstream_group = "chain-group"
714"#,
715            expected_equivalence: EquivalenceTarget::Payload,
716            normalization: NormalizationRules {
717                strip_log_prefixes: true,
718                normalize_ports: true,
719                ..Default::default()
720            },
721            platform: PlatformRequirements::default(),
722            timeout: Duration::from_secs(15),
723            category: ScenarioCategory::Chains,
724        },
725        OracleScenario {
726            id: "chain.ss_to_socks5",
727            capability_ids: vec!["routing.chain_ss_socks5"],
728            description: "Shadowsocks chained through SOCKS5 upstream",
729            pproxy_args: vec![
730                "-l",
731                "ss://127.0.0.1:{PORT}#testuser:testpass",
732                "-r",
733                "socks5://127.0.0.1:{UPSTREAM_PORT}",
734            ],
735            eggress_toml: r#"
736version = 1
737
738[[listeners]]
739name = "test"
740bind = "127.0.0.1:{PORT}"
741protocols = ["shadowsocks"]
742
743[listeners.shadowsocks]
744password = "testpass"
745method = "aes-256-gcm"
746
747[[upstreams]]
748id = "upstream-0"
749uri = "socks5://127.0.0.1:{UPSTREAM_PORT}"
750
751[[upstream_groups]]
752id = "chain-group"
753members = ["upstream-0"]
754
755[[rules]]
756id = "route-all"
757upstream_group = "chain-group"
758"#,
759            expected_equivalence: EquivalenceTarget::Payload,
760            normalization: NormalizationRules {
761                strip_log_prefixes: true,
762                normalize_ports: true,
763                ..Default::default()
764            },
765            platform: PlatformRequirements::default(),
766            timeout: Duration::from_secs(15),
767            category: ScenarioCategory::Chains,
768        },
769    ]
770}
771
772// ===== Rules (5 scenarios) =====
773
774fn rule_scenarios() -> Vec<OracleScenario> {
775    vec![
776        OracleScenario {
777            id: "rules.reject_ip",
778            capability_ids: vec!["routing.reject_ip"],
779            description: "Rule rejecting connections to specific IP",
780            pproxy_args: vec![
781                "-l",
782                "socks5://127.0.0.1:{PORT}",
783                "-r",
784                "direct",
785                "-b",
786                "127.0.0.2",
787            ],
788            eggress_toml: r#"
789version = 1
790
791[[listeners]]
792name = "test"
793bind = "127.0.0.1:{PORT}"
794protocols = ["socks5"]
795
796[[rules]]
797id = "reject-ip"
798host_regex = "127\\.0\\.0\\.2"
799reject = "blocked"
800"#,
801            expected_equivalence: EquivalenceTarget::CoarseResult,
802            normalization: NormalizationRules {
803                strip_log_prefixes: true,
804                normalize_ports: true,
805                ..Default::default()
806            },
807            platform: PlatformRequirements::default(),
808            timeout: Duration::from_secs(10),
809            category: ScenarioCategory::Rules,
810        },
811        OracleScenario {
812            id: "rules.reject_domain",
813            capability_ids: vec!["routing.reject_domain"],
814            description: "Rule rejecting connections to specific domain",
815            pproxy_args: vec![
816                "-l",
817                "socks5://127.0.0.1:{PORT}",
818                "-r",
819                "direct",
820                "-b",
821                "blocked.example.com",
822            ],
823            eggress_toml: r#"
824version = 1
825
826[[listeners]]
827name = "test"
828bind = "127.0.0.1:{PORT}"
829protocols = ["socks5"]
830
831[[rules]]
832id = "reject-domain"
833host_regex = "blocked\\.example\\.com"
834reject = "blocked"
835"#,
836            expected_equivalence: EquivalenceTarget::CoarseResult,
837            normalization: NormalizationRules {
838                strip_log_prefixes: true,
839                normalize_ports: true,
840                ..Default::default()
841            },
842            platform: PlatformRequirements::default(),
843            timeout: Duration::from_secs(10),
844            category: ScenarioCategory::Rules,
845        },
846        OracleScenario {
847            id: "rules.allow_all",
848            capability_ids: vec!["routing.allow_all"],
849            description: "Rule allowing all connections (default behavior)",
850            pproxy_args: vec!["-l", "socks5://127.0.0.1:{PORT}", "-r", "direct"],
851            eggress_toml: r#"
852version = 1
853
854[[listeners]]
855name = "test"
856bind = "127.0.0.1:{PORT}"
857protocols = ["socks5"]
858"#,
859            expected_equivalence: EquivalenceTarget::Payload,
860            normalization: NormalizationRules {
861                strip_log_prefixes: true,
862                normalize_ports: true,
863                ..Default::default()
864            },
865            platform: PlatformRequirements::default(),
866            timeout: Duration::from_secs(10),
867            category: ScenarioCategory::Rules,
868        },
869        OracleScenario {
870            id: "rules.block_reject",
871            capability_ids: vec!["routing.block_reject"],
872            description: "Block action rejecting connections",
873            pproxy_args: vec![
874                "-l",
875                "socks5://127.0.0.1:{PORT}",
876                "-r",
877                "direct",
878                "-b",
879                "127.0.0.3",
880            ],
881            eggress_toml: r#"
882version = 1
883
884[[listeners]]
885name = "test"
886bind = "127.0.0.1:{PORT}"
887protocols = ["socks5"]
888
889[[rules]]
890id = "reject-b"
891host_regex = "127\\.0\\.0\\.3"
892reject = "blocked"
893"#,
894            expected_equivalence: EquivalenceTarget::CoarseResult,
895            normalization: NormalizationRules {
896                strip_log_prefixes: true,
897                normalize_ports: true,
898                ..Default::default()
899            },
900            platform: PlatformRequirements::default(),
901            timeout: Duration::from_secs(10),
902            category: ScenarioCategory::Rules,
903        },
904        OracleScenario {
905            id: "rules.multiple_reject",
906            capability_ids: vec!["routing.multiple_reject"],
907            description: "Multiple reject rules with different targets",
908            pproxy_args: vec![
909                "-l",
910                "socks5://127.0.0.1:{PORT}",
911                "-r",
912                "direct",
913                "-b",
914                "127.0.0.4",
915                "-b",
916                "127.0.0.5",
917            ],
918            eggress_toml: r#"
919version = 1
920
921[[listeners]]
922name = "test"
923bind = "127.0.0.1:{PORT}"
924protocols = ["socks5"]
925
926[[rules]]
927id = "reject-c"
928host_regex = "127\\.0\\.0\\.4"
929reject = "blocked"
930
931[[rules]]
932id = "reject-d"
933host_regex = "127\\.0\\.0\\.5"
934reject = "blocked"
935"#,
936            expected_equivalence: EquivalenceTarget::CoarseResult,
937            normalization: NormalizationRules {
938                strip_log_prefixes: true,
939                normalize_ports: true,
940                ..Default::default()
941            },
942            platform: PlatformRequirements::default(),
943            timeout: Duration::from_secs(10),
944            category: ScenarioCategory::Rules,
945        },
946    ]
947}
948
949// ===== UDP (4 scenarios) =====
950
951fn udp_scenarios() -> Vec<OracleScenario> {
952    vec![
953        OracleScenario {
954            id: "udp.socks5_associate",
955            capability_ids: vec!["protocol.socks5_udp_associate"],
956            description: "SOCKS5 UDP ASSOCIATE lifecycle",
957            pproxy_args: vec!["-l", "socks5://127.0.0.1:{PORT}", "-r", "direct"],
958            eggress_toml: r#"
959version = 1
960
961[[listeners]]
962name = "test"
963bind = "127.0.0.1:{PORT}"
964protocols = ["socks5"]
965"#,
966            expected_equivalence: EquivalenceTarget::CoarseResult,
967            normalization: NormalizationRules {
968                strip_log_prefixes: true,
969                normalize_ports: true,
970                ..Default::default()
971            },
972            platform: PlatformRequirements::default(),
973            timeout: Duration::from_secs(10),
974            category: ScenarioCategory::Udp,
975        },
976        OracleScenario {
977            id: "udp.socks5_relay",
978            capability_ids: vec!["protocol.socks5_udp_relay"],
979            description: "SOCKS5 UDP relay with echo payload",
980            pproxy_args: vec!["-l", "socks5://127.0.0.1:{PORT}", "-r", "direct"],
981            eggress_toml: r#"
982version = 1
983
984[[listeners]]
985name = "test"
986bind = "127.0.0.1:{PORT}"
987protocols = ["socks5"]
988"#,
989            expected_equivalence: EquivalenceTarget::Payload,
990            normalization: NormalizationRules {
991                strip_log_prefixes: true,
992                normalize_ports: true,
993                ..Default::default()
994            },
995            platform: PlatformRequirements::default(),
996            timeout: Duration::from_secs(10),
997            category: ScenarioCategory::Udp,
998        },
999        OracleScenario {
1000            id: "udp.standalone",
1001            capability_ids: vec!["protocol.standalone_udp"],
1002            description: "Standalone UDP relay (pproxy-compatible mode)",
1003            pproxy_args: vec!["-l", "udp://127.0.0.1:{PORT}", "-r", "direct"],
1004            eggress_toml: r#"
1005version = 1
1006
1007[[listeners]]
1008name = "test"
1009bind = "127.0.0.1:{PORT}"
1010protocols = ["socks5"]
1011
1012[listeners.udp]
1013mode = "standalone_pproxy_udp"
1014"#,
1015            expected_equivalence: EquivalenceTarget::Payload,
1016            normalization: NormalizationRules {
1017                strip_log_prefixes: true,
1018                normalize_ports: true,
1019                ..Default::default()
1020            },
1021            platform: PlatformRequirements::default(),
1022            timeout: Duration::from_secs(10),
1023            category: ScenarioCategory::Udp,
1024        },
1025        OracleScenario {
1026            id: "udp.echo_roundtrip",
1027            capability_ids: vec!["protocol.udp_echo_roundtrip"],
1028            description: "UDP echo roundtrip through SOCKS5 proxy",
1029            pproxy_args: vec!["-l", "socks5://127.0.0.1:{PORT}", "-r", "direct"],
1030            eggress_toml: r#"
1031version = 1
1032
1033[[listeners]]
1034name = "test"
1035bind = "127.0.0.1:{PORT}"
1036protocols = ["socks5"]
1037"#,
1038            expected_equivalence: EquivalenceTarget::Payload,
1039            normalization: NormalizationRules {
1040                strip_log_prefixes: true,
1041                normalize_ports: true,
1042                ..Default::default()
1043            },
1044            platform: PlatformRequirements::default(),
1045            timeout: Duration::from_secs(10),
1046            category: ScenarioCategory::Udp,
1047        },
1048    ]
1049}
1050
1051#[cfg(test)]
1052mod tests {
1053    use super::*;
1054
1055    #[test]
1056    fn all_scenarios_have_unique_ids() {
1057        let scenarios = all_scenarios();
1058        let mut ids: Vec<&str> = scenarios.iter().map(|s| s.id).collect();
1059        ids.sort();
1060        ids.dedup();
1061        assert_eq!(ids.len(), scenarios.len(), "duplicate scenario IDs found");
1062    }
1063
1064    #[test]
1065    fn all_scenarios_have_nonempty_capabilities() {
1066        for scenario in all_scenarios() {
1067            assert!(
1068                !scenario.capability_ids.is_empty(),
1069                "scenario {} has no capability IDs",
1070                scenario.id
1071            );
1072        }
1073    }
1074
1075    #[test]
1076    fn scenario_count_by_category() {
1077        assert_eq!(cli_defaults_scenarios().len(), 7);
1078        assert_eq!(http_socks_tcp_scenarios().len(), 10);
1079        assert_eq!(chain_scenarios().len(), 5);
1080        assert_eq!(rule_scenarios().len(), 5);
1081        assert_eq!(udp_scenarios().len(), 4);
1082        assert_eq!(all_scenarios().len(), 31);
1083    }
1084
1085    #[test]
1086    fn find_scenario_by_id() {
1087        assert!(find_scenario("cli.socks5_default").is_some());
1088        assert!(find_scenario("nonexistent").is_none());
1089    }
1090
1091    #[test]
1092    fn scenarios_for_category_filter() {
1093        let cli = scenarios_for_category(ScenarioCategory::CliDefaults);
1094        assert_eq!(cli.len(), 7);
1095        for s in &cli {
1096            assert_eq!(s.category, ScenarioCategory::CliDefaults);
1097        }
1098    }
1099
1100    /// Validate that every scenario's `eggress_toml` parses and validates
1101    /// through the real config schema after placeholder substitution. This
1102    /// catches schema regressions in oracle scenario TOML before they can
1103    /// hide behind the `#[ignore]` gate.
1104    ///
1105    /// We deliberately stop at `validate_config`: scenarios that load TLS
1106    /// certificates or open files require the runtime fixtures to exist, and
1107    /// the runtime startup is exercised by the gated oracle comparison tests.
1108    #[test]
1109    fn scenario_eggress_toml_parses() {
1110        let port_substitution = |scenario: &OracleScenario, port: u16| -> String {
1111            scenario
1112                .eggress_toml
1113                .replace("{PORT}", &port.to_string())
1114                .replace("{PORT2}", &(port + 1).to_string())
1115                .replace("{ECHO_PORT}", &(port + 100).to_string())
1116                .replace("{UPSTREAM_PORT}", &(port + 100).to_string())
1117        };
1118
1119        for scenario in all_scenarios() {
1120            let toml = port_substitution(&scenario, 18080);
1121
1122            let parsed: Result<eggress_config::model::ConfigFile, _> = toml::from_str(&toml);
1123            let config = match parsed {
1124                Ok(c) => c,
1125                Err(e) => panic!(
1126                    "scenario {} produced TOML that failed to parse: {e}\nTOML was:\n{toml}",
1127                    scenario.id
1128                ),
1129            };
1130
1131            if let Err(errors) = eggress_config::validate::validate_config(&config) {
1132                let messages: Vec<String> = errors.iter().map(|e| e.to_string()).collect();
1133                panic!(
1134                    "scenario {} produced TOML that failed validation: {}\nTOML was:\n{toml}",
1135                    scenario.id,
1136                    messages.join("; ")
1137                );
1138            }
1139        }
1140    }
1141}