1use std::net::SocketAddr;
23use std::time::Duration;
24use tokio::io::AsyncReadExt;
25
26pub const GATE_VAR: &str = "EGRESS_RUN_PPROXY_DIFFERENTIAL";
28
29pub const PINNED_PPROXY_VERSION: &str = "2.7.9";
31
32pub const ORACLE_PYTHON_VAR: &str = "EGRESS_ORACLE_PYTHON";
39
40pub const LEGACY_PYTHON_VAR: &str = "EGGRESS_PYTHON_BIN";
45
46pub const PYTHON_BIN_VAR: &str = LEGACY_PYTHON_VAR;
48
49pub fn differential_gate_enabled() -> bool {
51 std::env::var(GATE_VAR).map(|v| v == "1").unwrap_or(false)
52}
53
54pub 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
73fn 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
102pub 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
141pub 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
199pub struct ProcessGuard {
207 child: Option<std::process::Child>,
208}
209
210impl ProcessGuard {
211 pub fn new(child: std::process::Child) -> Self {
213 Self { child: Some(child) }
214 }
215
216 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 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
247pub 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
266pub 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
287pub 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
300pub 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
317pub 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
328pub 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
345pub fn build_socks5_udp_packet(target: SocketAddr, payload: &[u8]) -> Vec<u8> {
349 let mut pkt = vec![0x00, 0x00, 0x00]; match target.ip() {
351 std::net::IpAddr::V4(ip) => {
352 pkt.push(0x01); pkt.extend_from_slice(&ip.octets());
354 }
355 std::net::IpAddr::V6(ip) => {
356 pkt.push(0x04); 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
365pub fn build_socks5_udp_packet_domain(host: &str, port: u16, payload: &[u8]) -> Vec<u8> {
367 let mut pkt = vec![0x00, 0x00, 0x00]; pkt.push(0x03); 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
376pub fn build_socks5_udp_packet_frag(target: SocketAddr, frag: u8, payload: &[u8]) -> Vec<u8> {
378 let mut pkt = vec![0x00, 0x00, frag]; 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
394pub 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, 0x04 => 4 + 16 + 2, 0x03 => {
407 if datagram.len() < 5 {
408 return vec![];
409 }
410 let domain_len = datagram[4] as usize;
411 4 + 1 + domain_len + 2 }
413 _ => return vec![],
414 };
415 if datagram.len() <= header_len {
416 return vec![];
417 }
418 datagram[header_len..].to_vec()
419}
420
421pub 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
437pub 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, Ok(Ok(n)) => buf.extend_from_slice(&tmp[..n]),
458 Ok(Err(_)) => break,
459 Err(_) => break, }
461 }
462 buf
463}
464
465pub 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
489pub 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
512pub 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 }
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 eprintln!("both failed (expected): {label_a}: {e_a}, {label_b}: {e_b}");
535 }
536 }
537}
538
539pub 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
551pub 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 std::env::set_var(ORACLE_PYTHON_VAR, "/nonexistent/oracle_py");
632 std::env::set_var(LEGACY_PYTHON_VAR, "/nonexistent/legacy_py");
633
634 let _result = std::panic::catch_unwind(|| {
638 find_python_binary();
639 });
640
641 });
646 }
647}