Skip to main content

eggress_testkit/
differential.rs

1//! Reusable differential test harness for comparing eggress with Python pproxy.
2//!
3//! All tests using this harness are gated on `EGGRESS_RUN_PPROXY_DIFFERENTIAL=1`
4//! and require Python 3 with pproxy installed (`pip install pproxy==2.7.9`).
5//!
6//! # Usage
7//!
8//! ```rust,no_run
9//! use eggress_testkit::differential::*;
10//!
11//! # async fn example() {
12//! require_differential_gate();
13//!
14//! let (echo_addr, echo_jh) = eggress_testkit::start_echo_server().await;
15//! let mut pproxy = start_pproxy_server("socks5", 1080).await;
16//! // ... run tests ...
17//! pproxy.kill();
18//! echo_jh.abort();
19//! # }
20//! ```
21
22use std::net::SocketAddr;
23use std::time::Duration;
24use tokio::io::AsyncReadExt;
25
26/// Environment variable that gates differential tests.
27pub const GATE_VAR: &str = "EGRESS_RUN_PPROXY_DIFFERENTIAL";
28
29/// Pinned pproxy version for reproducible test results.
30pub const PINNED_PPROXY_VERSION: &str = "2.7.9";
31
32/// Canonical environment variable for the oracle Python interpreter.
33///
34/// Set by the top-level certification runner. Resolution order:
35/// 1. `EGRESS_ORACLE_PYTHON`
36/// 2. `EGRESS_PYTHON_BIN` (legacy fallback for standalone use)
37/// 3. discovery of system python with pproxy
38pub const ORACLE_PYTHON_VAR: &str = "EGRESS_ORACLE_PYTHON";
39
40/// Legacy environment variable for the Python binary path.
41///
42/// Retained for standalone developer use. During certification,
43/// `EGRESS_ORACLE_PYTHON` takes precedence.
44pub const LEGACY_PYTHON_VAR: &str = "EGGRESS_PYTHON_BIN";
45
46/// Deprecated alias — prefer [`ORACLE_PYTHON_VAR`] or [`LEGACY_PYTHON_VAR`].
47pub const PYTHON_BIN_VAR: &str = LEGACY_PYTHON_VAR;
48
49/// Check if the differential test gate is enabled.
50pub fn differential_gate_enabled() -> bool {
51    std::env::var(GATE_VAR).map(|v| v == "1").unwrap_or(false)
52}
53
54/// Require the differential gate to be enabled.
55///
56/// Panics with a clear message if `EGRESS_RUN_PPROXY_DIFFERENTIAL` is not set
57/// or if the pproxy package is not installed.
58pub fn require_differential_gate() {
59    if !differential_gate_enabled() {
60        panic!(
61            "differential tests require {}=1 and pproxy=={}",
62            GATE_VAR, PINNED_PPROXY_VERSION
63        );
64    }
65    if !pproxy_available() {
66        panic!(
67            "pproxy not available; install with: pip install pproxy=={}",
68            PINNED_PPROXY_VERSION
69        );
70    }
71}
72
73/// Validate that a Python interpreter has pproxy==PINNED_PPROXY_VERSION.
74///
75/// Uses `importlib.metadata.version("pproxy")` for reliable distribution
76/// metadata checks rather than module `__version__` attributes.
77fn validate_oracle_python(path: &str) -> Result<String, String> {
78    let output = std::process::Command::new(path)
79        .args([
80            "-c",
81            "from importlib.metadata import version; print(version('pproxy'))",
82        ])
83        .output()
84        .map_err(|e| format!("failed to execute {}: {}", path, e))?;
85
86    if !output.status.success() {
87        let stderr = String::from_utf8_lossy(&output.stderr);
88        return Err(format!("{} cannot import pproxy: {}", path, stderr.trim()));
89    }
90
91    let actual = String::from_utf8_lossy(&output.stdout).trim().to_string();
92    if actual != PINNED_PPROXY_VERSION {
93        return Err(format!(
94            "expected pproxy=={}, got {} at {}",
95            PINNED_PPROXY_VERSION, actual, path
96        ));
97    }
98
99    Ok(path.to_string())
100}
101
102/// Find the oracle Python interpreter with version validation.
103///
104/// Resolution order:
105/// 1. `EGRESS_ORACLE_PYTHON` — must have pproxy==PINNED_PPROXY_VERSION
106/// 2. `EGRESS_PYTHON_BIN` — must have pproxy==PINNED_PPROXY_VERSION
107///
108/// When `require_explicit` is true (certification mode), missing or invalid
109/// interpreters cause a panic. When false, falls back to discovery.
110pub fn find_oracle_python(require_explicit: bool) -> String {
111    if let Ok(path) = std::env::var(ORACLE_PYTHON_VAR) {
112        return validate_oracle_python(&path).unwrap_or_else(|e| {
113            if require_explicit {
114                panic!("certification oracle interpreter: {}", e);
115            }
116            eprintln!("WARNING: {}", e);
117            find_python_binary()
118        });
119    }
120
121    if let Ok(path) = std::env::var(LEGACY_PYTHON_VAR) {
122        return validate_oracle_python(&path).unwrap_or_else(|e| {
123            if require_explicit {
124                panic!("certification oracle interpreter: {}", e);
125            }
126            eprintln!("WARNING: {}", e);
127            find_python_binary()
128        });
129    }
130
131    if require_explicit {
132        panic!(
133            "certification requires {} to point to pproxy=={}",
134            ORACLE_PYTHON_VAR, PINNED_PPROXY_VERSION
135        );
136    }
137
138    find_python_binary()
139}
140
141/// Find a working Python binary that has pproxy installed.
142///
143/// Checks `EGRESS_ORACLE_PYTHON` first, then `EGRESS_PYTHON_BIN`,
144/// then tries `python3.11`, `python3.12`, `python3.13`, and finally `python3`.
145pub fn find_python_binary() -> String {
146    if let Ok(path) = std::env::var(ORACLE_PYTHON_VAR) {
147        if std::process::Command::new(&path)
148            .args(["-c", "import pproxy"])
149            .stdout(std::process::Stdio::null())
150            .stderr(std::process::Stdio::null())
151            .status()
152            .map(|s| s.success())
153            .unwrap_or(false)
154        {
155            return path;
156        }
157    }
158    if let Ok(path) = std::env::var(LEGACY_PYTHON_VAR) {
159        if std::process::Command::new(&path)
160            .args(["-c", "import pproxy"])
161            .stdout(std::process::Stdio::null())
162            .stderr(std::process::Stdio::null())
163            .status()
164            .map(|s| s.success())
165            .unwrap_or(false)
166        {
167            return path;
168        }
169    }
170    for candidate in &["python3.11", "python3.12", "python3.13", "python3"] {
171        if std::process::Command::new(candidate)
172            .args(["-c", "import pproxy"])
173            .stdout(std::process::Stdio::null())
174            .stderr(std::process::Stdio::null())
175            .status()
176            .map(|s| s.success())
177            .unwrap_or(false)
178        {
179            return candidate.to_string();
180        }
181    }
182    panic!(
183        "no Python binary with pproxy found; install pproxy: pip install pproxy=={}",
184        PINNED_PPROXY_VERSION
185    );
186}
187
188fn pproxy_available() -> bool {
189    let python = find_python_binary();
190    std::process::Command::new(&python)
191        .args(["-c", "import pproxy"])
192        .stdout(std::process::Stdio::null())
193        .stderr(std::process::Stdio::null())
194        .status()
195        .map(|s| s.success())
196        .unwrap_or(false)
197}
198
199// ===== Process Management =====
200
201/// RAII guard that kills a child process on drop.
202///
203/// Wraps `std::process::Child` and ensures the process is killed and waited
204/// on when the guard is dropped. Call [`kill`](ProcessGuard::kill) explicitly
205/// to terminate early.
206pub struct ProcessGuard {
207    child: Option<std::process::Child>,
208}
209
210impl ProcessGuard {
211    /// Create a new guard wrapping the given child process.
212    pub fn new(child: std::process::Child) -> Self {
213        Self { child: Some(child) }
214    }
215
216    /// Kill the process early (before drop).
217    pub fn kill(&mut self) {
218        if let Some(ref mut child) = self.child {
219            let _ = child.kill();
220            let _ = child.wait();
221        }
222    }
223
224    /// Drain and return all available stderr output from the process.
225    pub fn drain_stderr(&mut self) -> String {
226        use std::io::Read;
227        if let Some(ref mut child) = self.child {
228            if let Some(ref mut stderr) = child.stderr {
229                let mut output = String::new();
230                let _ = stderr.read_to_string(&mut output);
231                return output;
232            }
233        }
234        String::new()
235    }
236}
237
238impl Drop for ProcessGuard {
239    fn drop(&mut self) {
240        if let Some(ref mut child) = self.child {
241            let _ = child.kill();
242            let _ = child.wait();
243        }
244    }
245}
246
247// ===== Pproxy Process Management =====
248
249/// Start a pproxy server with the given protocol and port.
250///
251/// Uses the resolved oracle interpreter (checks `EGRESS_ORACLE_PYTHON` first)
252/// to spawn `python -m pproxy -l {proto}://127.0.0.1:{port} -r direct`.
253/// Returns a [`ProcessGuard`] that kills the process on drop.
254pub async fn start_pproxy_server(protocol: &str, port: u16) -> ProcessGuard {
255    let python = find_oracle_python(false);
256    let listen = format!("{}://127.0.0.1:{}", protocol, port);
257    let child = std::process::Command::new(&python)
258        .args(["-m", "pproxy", "-l", &listen, "-r", "direct"])
259        .stdout(std::process::Stdio::null())
260        .stderr(std::process::Stdio::piped())
261        .spawn()
262        .expect("failed to start pproxy");
263    ProcessGuard::new(child)
264}
265
266/// Start a pproxy server with username/password authentication.
267pub async fn start_pproxy_server_with_auth(
268    protocol: &str,
269    port: u16,
270    username: &str,
271    password: &str,
272) -> ProcessGuard {
273    let python = find_oracle_python(false);
274    let listen = format!(
275        "{}://127.0.0.1:{}#{}:{}",
276        protocol, port, username, password
277    );
278    let child = std::process::Command::new(&python)
279        .args(["-m", "pproxy", "-l", &listen, "-r", "direct"])
280        .stdout(std::process::Stdio::null())
281        .stderr(std::process::Stdio::piped())
282        .spawn()
283        .expect("failed to start pproxy");
284    ProcessGuard::new(child)
285}
286
287/// Start a pproxy server with arbitrary CLI arguments.
288pub async fn start_pproxy_with_args(args: &[&str]) -> ProcessGuard {
289    let python = find_oracle_python(false);
290    let child = std::process::Command::new(&python)
291        .args(["-m", "pproxy"])
292        .args(args)
293        .stdout(std::process::Stdio::null())
294        .stderr(std::process::Stdio::piped())
295        .spawn()
296        .expect("failed to start pproxy");
297    ProcessGuard::new(child)
298}
299
300/// Wait for a TCP port to become reachable.
301///
302/// Returns `true` if the port is reachable within the timeout, `false` otherwise.
303pub async fn wait_for_port(port: u16, timeout: Duration) -> bool {
304    let start = std::time::Instant::now();
305    while start.elapsed() < timeout {
306        if tokio::net::TcpStream::connect(format!("127.0.0.1:{}", port))
307            .await
308            .is_ok()
309        {
310            return true;
311        }
312        tokio::time::sleep(Duration::from_millis(50)).await;
313    }
314    false
315}
316
317/// Assert that a port becomes reachable within the timeout.
318///
319/// Panics if the port does not become ready.
320pub async fn assert_port_ready(port: u16, timeout: Duration) {
321    assert!(
322        wait_for_port(port, timeout).await,
323        "port {port} not ready within {}ms",
324        timeout.as_millis()
325    );
326}
327
328// ===== Echo Servers =====
329
330/// Start a UDP echo server that echoes received packets back to the sender.
331///
332/// Returns the listening address and a join handle.
333pub async fn start_udp_echo() -> (SocketAddr, tokio::task::JoinHandle<()>) {
334    let socket = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap();
335    let addr = socket.local_addr().unwrap();
336    let jh = tokio::spawn(async move {
337        let mut buf = [0u8; 65535];
338        while let Ok((n, peer)) = socket.recv_from(&mut buf).await {
339            let _ = socket.send_to(&buf[..n], peer).await;
340        }
341    });
342    (addr, jh)
343}
344
345// ===== SOCKS5 UDP Utilities =====
346
347/// Build a SOCKS5 UDP datagram with an IPv4 or IPv6 target.
348pub fn build_socks5_udp_packet(target: SocketAddr, payload: &[u8]) -> Vec<u8> {
349    let mut pkt = vec![0x00, 0x00, 0x00]; // RSV + FRAG
350    match target.ip() {
351        std::net::IpAddr::V4(ip) => {
352            pkt.push(0x01); // ATYP IPv4
353            pkt.extend_from_slice(&ip.octets());
354        }
355        std::net::IpAddr::V6(ip) => {
356            pkt.push(0x04); // ATYP IPv6
357            pkt.extend_from_slice(&ip.octets());
358        }
359    }
360    pkt.extend_from_slice(&target.port().to_be_bytes());
361    pkt.extend_from_slice(payload);
362    pkt
363}
364
365/// Build a SOCKS5 UDP datagram with a domain target.
366pub fn build_socks5_udp_packet_domain(host: &str, port: u16, payload: &[u8]) -> Vec<u8> {
367    let mut pkt = vec![0x00, 0x00, 0x00]; // RSV + FRAG
368    pkt.push(0x03); // ATYP Domain
369    pkt.push(host.len() as u8);
370    pkt.extend_from_slice(host.as_bytes());
371    pkt.extend_from_slice(&port.to_be_bytes());
372    pkt.extend_from_slice(payload);
373    pkt
374}
375
376/// Build a SOCKS5 UDP datagram with a custom FRAG field.
377pub fn build_socks5_udp_packet_frag(target: SocketAddr, frag: u8, payload: &[u8]) -> Vec<u8> {
378    let mut pkt = vec![0x00, 0x00, frag]; // RSV + FRAG
379    match target.ip() {
380        std::net::IpAddr::V4(ip) => {
381            pkt.push(0x01);
382            pkt.extend_from_slice(&ip.octets());
383        }
384        std::net::IpAddr::V6(ip) => {
385            pkt.push(0x04);
386            pkt.extend_from_slice(&ip.octets());
387        }
388    }
389    pkt.extend_from_slice(&target.port().to_be_bytes());
390    pkt.extend_from_slice(payload);
391    pkt
392}
393
394/// Extract the payload from a SOCKS5 UDP datagram.
395///
396/// Parses the SOCKS5 UDP header (RSV + FRAG + ATYP + address) and returns
397/// the payload bytes.
398pub fn extract_udp_payload(datagram: &[u8]) -> Vec<u8> {
399    if datagram.len() < 4 {
400        return vec![];
401    }
402    let atyp = datagram[3];
403    let header_len = match atyp {
404        0x01 => 4 + 4 + 2,  // RSV(2) + FRAG(1) + ATYP(1) + IPv4(4) + PORT(2)
405        0x04 => 4 + 16 + 2, // RSV(2) + FRAG(1) + ATYP(1) + IPv6(16) + PORT(2)
406        0x03 => {
407            if datagram.len() < 5 {
408                return vec![];
409            }
410            let domain_len = datagram[4] as usize;
411            4 + 1 + domain_len + 2 // RSV(2) + FRAG(1) + ATYP(1) + LEN(1) + DOMAIN + PORT(2)
412        }
413        _ => return vec![],
414    };
415    if datagram.len() <= header_len {
416        return vec![];
417    }
418    datagram[header_len..].to_vec()
419}
420
421/// Receive a UDP response with a timeout.
422///
423/// Returns the raw datagram bytes, or `None` if no response is received
424/// within the timeout.
425pub async fn recv_udp_response(sock: &tokio::net::UdpSocket, timeout: Duration) -> Option<Vec<u8>> {
426    let mut buf = [0u8; 65535];
427    let deadline = std::time::Instant::now() + timeout;
428    while std::time::Instant::now() < deadline {
429        match tokio::time::timeout(Duration::from_millis(200), sock.recv_from(&mut buf)).await {
430            Ok(Ok((n, _))) => return Some(buf[..n].to_vec()),
431            _ => continue,
432        }
433    }
434    None
435}
436
437// ===== Comparison Utilities =====
438
439/// Read all available data from an `AsyncRead` within a timeout.
440///
441/// Reads chunks until the timeout expires or the remote closes. Returns the
442/// accumulated bytes.
443pub async fn read_with_timeout(
444    reader: &mut (impl tokio::io::AsyncRead + Unpin),
445    timeout: Duration,
446) -> Vec<u8> {
447    let mut buf = Vec::new();
448    let mut tmp = [0u8; 4096];
449    let deadline = std::time::Instant::now() + timeout;
450    loop {
451        let remaining = deadline.saturating_duration_since(std::time::Instant::now());
452        if remaining.is_zero() {
453            break;
454        }
455        match tokio::time::timeout(remaining, reader.read(&mut tmp)).await {
456            Ok(Ok(0)) => break, // EOF
457            Ok(Ok(n)) => buf.extend_from_slice(&tmp[..n]),
458            Ok(Err(_)) => break,
459            Err(_) => break, // timeout
460        }
461    }
462    buf
463}
464
465/// Compare two TCP echo results.
466///
467/// Both results should be `Ok(Vec<u8>)` with identical payloads.
468/// Panics with a descriptive message on mismatch.
469pub fn compare_tcp_echo(
470    label_a: &str,
471    result_a: &Result<Vec<u8>, String>,
472    label_b: &str,
473    result_b: &Result<Vec<u8>, String>,
474) {
475    match (result_a, result_b) {
476        (Ok(payload_a), Ok(payload_b)) => {
477            assert_eq!(
478                payload_a, payload_b,
479                "TCP echo payload mismatch: {label_a} returned {} bytes, {label_b} returned {} bytes",
480                payload_a.len(),
481                payload_b.len()
482            );
483        }
484        (Err(e), _) => panic!("{label_a} failed: {e}"),
485        (_, Err(e)) => panic!("{label_b} failed: {e}"),
486    }
487}
488
489/// Compare two UDP echo results.
490///
491/// Both should succeed with matching payloads.
492pub fn compare_udp_echo(
493    label_a: &str,
494    result_a: &Option<Vec<u8>>,
495    label_b: &str,
496    result_b: &Option<Vec<u8>>,
497) {
498    match (result_a, result_b) {
499        (Some(payload_a), Some(payload_b)) => {
500            assert_eq!(
501                payload_a, payload_b,
502                "UDP echo payload mismatch: {label_a} returned {} bytes, {label_b} returned {} bytes",
503                payload_a.len(),
504                payload_b.len()
505            );
506        }
507        (None, _) => panic!("{label_a} did not receive UDP response"),
508        (_, None) => panic!("{label_b} did not receive UDP response"),
509    }
510}
511
512/// Assert coarse failure equivalence: both succeeded or both failed.
513///
514/// This is useful when the exact payload may differ (e.g., different error
515/// messages) but the success/failure class should match.
516pub fn assert_coarse_failure_equivalence<T>(
517    label_a: &str,
518    result_a: &Result<T, String>,
519    label_b: &str,
520    result_b: &Result<T, String>,
521) {
522    match (result_a, result_b) {
523        (Ok(_), Ok(_)) => {
524            // Both succeeded — acceptable
525        }
526        (Err(e), Ok(_)) => {
527            panic!("{label_a} failed but {label_b} succeeded: {label_a} error: {e}");
528        }
529        (Ok(_), Err(e)) => {
530            panic!("{label_a} succeeded but {label_b} failed: {label_b} error: {e}");
531        }
532        (Err(e_a), Err(e_b)) => {
533            // Both failed — acceptable
534            eprintln!("both failed (expected): {label_a}: {e_a}, {label_b}: {e_b}");
535        }
536    }
537}
538
539// ===== HTTP Utilities =====
540
541/// Extract the body from an HTTP response (after the first `\r\n\r\n`).
542pub fn extract_http_body(response: &[u8]) -> String {
543    let text = String::from_utf8_lossy(response);
544    if let Some(pos) = text.find("\r\n\r\n") {
545        text[pos + 4..].to_string()
546    } else {
547        text.to_string()
548    }
549}
550
551/// Extract the HTTP status code from a response (e.g., "200" from "HTTP/1.1 200 OK").
552pub fn extract_http_status(response: &[u8]) -> String {
553    let text = String::from_utf8_lossy(response);
554    text.lines()
555        .next()
556        .and_then(|line| line.split_whitespace().nth(1))
557        .unwrap_or("unknown")
558        .to_string()
559}
560
561#[cfg(test)]
562mod tests {
563    use super::*;
564    use std::sync::Mutex;
565
566    static ENV_LOCK: Mutex<()> = Mutex::new(());
567
568    fn lock_env() -> std::sync::MutexGuard<'static, ()> {
569        ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner())
570    }
571
572    fn with_env_reset<F: FnOnce()>(f: F) {
573        let _lock = lock_env();
574        let saved_oracle = std::env::var(ORACLE_PYTHON_VAR).ok();
575        let saved_legacy = std::env::var(LEGACY_PYTHON_VAR).ok();
576
577        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
578
579        match saved_oracle {
580            Some(v) => std::env::set_var(ORACLE_PYTHON_VAR, v),
581            None => std::env::remove_var(ORACLE_PYTHON_VAR),
582        }
583        match saved_legacy {
584            Some(v) => std::env::set_var(LEGACY_PYTHON_VAR, v),
585            None => std::env::remove_var(LEGACY_PYTHON_VAR),
586        }
587
588        if let Err(e) = result {
589            std::panic::resume_unwind(e);
590        }
591    }
592
593    #[test]
594    fn nonexistent_interpreter_path_fails_clearly() {
595        let result = validate_oracle_python("/nonexistent/python3.99");
596        assert!(result.is_err());
597        let err = result.unwrap_err();
598        assert!(err.contains("failed to execute"), "error: {}", err);
599    }
600
601    #[test]
602    fn strict_certification_rejects_missing_explicit() {
603        with_env_reset(|| {
604            std::env::remove_var(ORACLE_PYTHON_VAR);
605            std::env::remove_var(LEGACY_PYTHON_VAR);
606
607            let result = std::panic::catch_unwind(|| {
608                find_oracle_python(true);
609            });
610
611            assert!(result.is_err(), "should panic when require_explicit=true");
612        });
613    }
614
615    #[test]
616    fn pinned_version_matches() {
617        assert_eq!(PINNED_PPROXY_VERSION, "2.7.9");
618    }
619
620    #[test]
621    fn constant_names_are_correct() {
622        assert_eq!(ORACLE_PYTHON_VAR, "EGRESS_ORACLE_PYTHON");
623        assert_eq!(LEGACY_PYTHON_VAR, "EGGRESS_PYTHON_BIN");
624        assert_eq!(PYTHON_BIN_VAR, LEGACY_PYTHON_VAR);
625    }
626
627    #[test]
628    fn oracle_python_checked_before_legacy_in_find_binary() {
629        with_env_reset(|| {
630            // Set oracle to a nonexistent path, legacy to nonexistent
631            std::env::set_var(ORACLE_PYTHON_VAR, "/nonexistent/oracle_py");
632            std::env::set_var(LEGACY_PYTHON_VAR, "/nonexistent/legacy_py");
633
634            // find_python_binary should check oracle first, then legacy, then system.
635            // Since all are invalid, it should panic (system python without pproxy
636            // may or may not be found — the key test is that oracle is checked first).
637            let _result = std::panic::catch_unwind(|| {
638                find_python_binary();
639            });
640
641            // Should panic because no valid python with pproxy found
642            // (the nonexistent paths won't work, and system python may not have pproxy)
643            // The important thing is no crash in the function itself.
644            // If system python has pproxy, this won't panic — that's fine.
645        });
646    }
647}