1- //! INV-19 guard: the icaptcha flow must actually call the service (consume the
1+ //! INV-19 guard: the icaptcha flow must actually call the service (consuming the
22//! mock), not short-circuit or make a live call. If a future change let
33//! `obtain_proof` skip the network (e.g. a stray `cfg(test)` relaxation, which
44//! INV-19/INV-20 warn is inert across crates), the `.assert()` calls below go
88//! see a request that ALSO leaks the DID / answer / API key to `DEFAULT_URL` or
99//! any other origin (a different host the mock never observes). To make the
1010//! "no live call" half of the guard load-bearing, the test blackholes every
11- //! non-loopback destination: `NO_PROXY` lets the loopback mock through while a
12- //! closed proxy port routes any other host nowhere, so the client contacting
13- //! anything but the injected mock fails the request instead of silently
14- //! succeeding. Point `cfg.url` at `DEFAULT_URL` and this test goes RED.
11+ //! non-loopback destination via a proxy observer: `NO_PROXY` lets the loopback
12+ //! mock through while every proxy variable routes any other host through a TCP
13+ //! listener that counts connections. Zero observed connections proves no leak
14+ //! reached the network on the exercised path.
15+ //!
16+ //! The observer design counts every accepted connection regardless of whether
17+ //! the caller propagates the transport error, so it catches leaks that a
18+ //! closed-port blackhole (which only surfaces propagating errors) would miss.
19+ //! To close the timing race inherent in a detached accept loop, the count is
20+ //! read only after a bounded drain: the test opens a flush connection and
21+ //! blocks until the accept thread has accepted *that specific connection*
22+ //! (tagged by its source port), not merely until any next counter tick.
23+ //! Because `accept()` hands out connections FIFO, every leak connection that
24+ //! completed its TCP handshake before the flush is counted before the flush —
25+ //! the assertion therefore covers all egress in flight when `obtain_proof`
26+ //! returned. A leak that starts its handshake after the drain is outside the
27+ //! window; that is the irreducible limit of observing a detached accept loop,
28+ //! and it is the remaining, well-defined gap this guard accepts.
29+ //!
30+ //! SCOPE: The observer only witnesses leaks issued on the path this test drives
31+ //! (a single passing round, 200 on both endpoints, no PoW, no Continue/Failed,
32+ //! no non-2xx response). A leak living on an error or retry branch makes no
33+ //! connection during the run and the zero-count assertion stays green. The
34+ //! guarantee covers only the exercised happy path and proxy-honoring transports;
35+ //! a future `.no_proxy()` or raw `TcpStream` bypasses the observer entirely.
1536//!
1637//! `ALL_PROXY` alone is NOT enough: reqwest honors the scheme-specific
1738//! `HTTPS_PROXY` / `https_proxy` (and the http equivalents) OVER `ALL_PROXY`, so
1839//! a runner with a working `HTTPS_PROXY` in its environment would route an https
1940//! leak to `DEFAULT_URL` (which is https) through that proxy while the loopback
20- //! mock is still consumed and this test stayed green. So we blackhole the
21- //! scheme-specific variables too. No restore is needed: this file has a single
22- //! `#[test]`, so it runs in its own process and the override never races a
23- //! sibling test.
41+ //! mock is still consumed and this test stayed green. So we blackhole the
42+ //! scheme-specific variables too.
43+
44+ use std:: net:: { TcpListener , TcpStream } ;
45+ use std:: sync:: { Arc , Condvar , Mutex } ;
2446
2547use icaptcha_client:: { obtain_proof, Challenge , IcaptchaCfg } ;
2648
49+ mod support;
50+
51+ /// A TCP listener that counts every accepted connection.
52+ ///
53+ /// Acts as the proxy target: any leaked connection to a non-loopback
54+ /// destination will be routed through this listener and counted. Every
55+ /// accepted connection is recorded by its source port so the test's own
56+ /// flush connection ([`ProxyObserver::drain`]) can be recognised and
57+ /// excluded from the leak count.
58+ struct ProxyObserver {
59+ _listener : Arc < TcpListener > ,
60+ port : u16 ,
61+ /// Source ports of every accepted connection, in accept order.
62+ accepted : Arc < Mutex < Vec < u16 > > > ,
63+ /// Source ports of the test's own flush connections (never counted).
64+ flush_ports : Arc < Mutex < Vec < u16 > > > ,
65+ /// Signalled after each accept so `drain` can wait on the flush.
66+ ack : Arc < Condvar > ,
67+ }
68+
69+ impl ProxyObserver {
70+ fn bind ( ) -> Self {
71+ let listener = Arc :: new ( TcpListener :: bind ( "127.0.0.1:0" ) . expect ( "bind proxy observer" ) ) ;
72+ let port = listener. local_addr ( ) . unwrap ( ) . port ( ) ;
73+ let accepted = Arc :: new ( Mutex :: new ( Vec :: new ( ) ) ) ;
74+ let flush_ports = Arc :: new ( Mutex :: new ( Vec :: new ( ) ) ) ;
75+ let ack = Arc :: new ( Condvar :: new ( ) ) ;
76+ let a = Arc :: clone ( & accepted) ;
77+ let c = Arc :: clone ( & ack) ;
78+ let l = Arc :: clone ( & listener) ;
79+ std:: thread:: spawn ( move || {
80+ for stream in l. incoming ( ) {
81+ let Ok ( stream) = stream else { continue } ;
82+ a. lock ( )
83+ . unwrap ( )
84+ . push ( stream. peer_addr ( ) . map ( |a| a. port ( ) ) . unwrap_or ( 0 ) ) ;
85+ c. notify_all ( ) ;
86+ }
87+ } ) ;
88+ ProxyObserver {
89+ _listener : listener,
90+ port,
91+ accepted,
92+ flush_ports,
93+ ack,
94+ }
95+ }
96+
97+ fn port ( & self ) -> u16 {
98+ self . port
99+ }
100+
101+ /// Number of accepted connections that are not the test's own flush
102+ /// connections — i.e. the leak count.
103+ fn count ( & self ) -> usize {
104+ let flush = self . flush_ports . lock ( ) . unwrap ( ) ;
105+ let accepted = self . accepted . lock ( ) . unwrap ( ) ;
106+ accepted. iter ( ) . filter ( |p| !flush. contains ( p) ) . count ( )
107+ }
108+
109+ /// Bounded acknowledgement barrier for the accept loop.
110+ ///
111+ /// Opens a flush connection and blocks until the accept thread has
112+ /// accepted *that connection* (identified by its source port), bounded by
113+ /// a timeout. Because `accept()` hands out connections FIFO, once the
114+ /// flush is accepted every connection that completed its handshake before
115+ /// it has been accepted and recorded too — so counting on afterwards
116+ /// cannot miss a leak that was already in flight. The flush connection
117+ /// itself is never counted.
118+ ///
119+ /// A counter-tick barrier would be unsound here: sampling "current + 1"
120+ /// and waiting for any increment lets a leak accepted after the sample
121+ /// satisfy the wait, and the flush is then miscounted as that leak.
122+ fn drain ( & self ) {
123+ let flush =
124+ TcpStream :: connect ( ( "127.0.0.1" , self . port ) ) . expect ( "observer flush connection" ) ;
125+ let flush_port = flush. local_addr ( ) . unwrap ( ) . port ( ) ;
126+ self . flush_ports . lock ( ) . unwrap ( ) . push ( flush_port) ;
127+ let mut accepted = self . accepted . lock ( ) . unwrap ( ) ;
128+ while !accepted. contains ( & flush_port) {
129+ let ( guard, timed_out) = self
130+ . ack
131+ . wait_timeout ( accepted, std:: time:: Duration :: from_secs ( 5 ) )
132+ . unwrap ( ) ;
133+ accepted = guard;
134+ assert ! (
135+ !timed_out. timed_out( ) ,
136+ "proxy observer drain timed out; accept loop stalled"
137+ ) ;
138+ }
139+ }
140+ }
141+
27142#[ test]
28143fn obtain_proof_consumes_the_mocked_service_and_makes_no_live_call ( ) {
29- // Blackhole every non-loopback origin: the loopback mock bypasses the proxy
30- // (NO_PROXY); any other host is forced through a closed port and fails.
31- // reqwest reads these at client-build time, and obtain_proof builds its
32- // client fresh, so this bounds the transport for the whole flow.
33- //
34- // Set the scheme-specific vars too (both cases), not just ALL_PROXY: reqwest
35- // gives HTTPS_PROXY/https_proxy precedence over ALL_PROXY for https URLs, so
36- // an ALL_PROXY-only blackhole leaves an https leak to DEFAULT_URL open on a
37- // runner whose environment already has a working HTTPS_PROXY.
38- let blackhole = "http://127.0.0.1:1" ;
39- std:: env:: set_var ( "NO_PROXY" , "127.0.0.1,localhost" ) ;
40- std:: env:: set_var ( "no_proxy" , "127.0.0.1,localhost" ) ;
41- for var in [
42- "ALL_PROXY" ,
43- "all_proxy" ,
44- "HTTPS_PROXY" ,
45- "https_proxy" ,
46- "HTTP_PROXY" ,
47- "http_proxy" ,
48- ] {
49- std:: env:: set_var ( var, blackhole) ;
50- }
144+ let observer = ProxyObserver :: bind ( ) ;
145+ let _guard = support:: arm_blackhole ( & format ! ( "http://127.0.0.1:{}" , observer. port( ) ) ) ;
51146
52147 let mut server = mockito:: Server :: new ( ) ;
53148
54- // The two endpoints the flow hits, each expected exactly once.
55149 let challenge = server
56150 . mock ( "POST" , "/v1/challenge" )
57151 . with_status ( 200 )
@@ -77,18 +171,29 @@ fn obtain_proof_consumes_the_mocked_service_and_makes_no_live_call() {
77171 api_key : None ,
78172 } ;
79173
80- // "anagram" is not a built-in solvable type, so the flow consults this
81- // solver callback. The answer body itself does not matter: the mocked
82- // /v1/answer returns "passed" regardless of what is submitted.
83174 let solve = |_c : & Challenge | Some ( "silent" . to_string ( ) ) ;
84175 let solver: & dyn Fn ( & Challenge ) -> Option < String > = & solve;
85176
177+ let count_before = observer. count ( ) ;
178+
86179 let proof = obtain_proof ( & cfg, Some ( solver) )
87180 . expect ( "obtain_proof should complete against the mocked service" ) ;
88181 assert_eq ! ( proof, "PROOF-XYZ" ) ;
89182
90- // INV-19: prove the client actually called the mocked endpoints. Had it made
91- // a live call or skipped the network, these would fail (mock never hit).
92183 challenge. assert ( ) ;
93184 answer. assert ( ) ;
185+
186+ // Drain the accept loop before asserting: the flush connection is not
187+ // counted, and because accept() is FIFO every leak connection that
188+ // completed its handshake before the flush is recorded before the flush
189+ // is — so the count after drain reflects all egress in flight during the
190+ // call.
191+ observer. drain ( ) ;
192+ let leak_count = observer. count ( ) - count_before;
193+
194+ assert_eq ! (
195+ leak_count, 0 ,
196+ "proxy observer caught leaked connections; \
197+ the blackhole failed to contain egress"
198+ ) ;
94199}
0 commit comments