Skip to content

Commit 93bc59e

Browse files
committed
Split a flow across two carriers, and provoke the three rejections that needed it
PAIR_TIMEOUT, METADATA_CONFLICT and SESSION_REPLACED have been marked "unreachable at this layer" in the coverage map since it was written. Each is a Portal's answer to something only two lanes can express — a half that never arrived, two halves that disagree, and a session claimed twice — so their messages have been tested since L1 and their provocation needed this. All three now come back from a real Portal. A split flow is two client-initiated lanes: OPEN on the uplink carrying the Target, ATTACH on the downlink carrying none, paired on (session_id, flow_id). The result arrives only on ATTACH, and that is not an ordering detail — the uplink is a lane the client writes and never reads, so there is nowhere else to put it. SplitFlow states the asymmetry as two fields rather than one transport, which is what makes it impossible to read from the half that will never answer. Authentication differs between the sides and is read off the transport rather than configured. A TLS lane is a connection, so every one carries its own AuthFrame; a QUIC connection outlives its lanes and carries one. A carrier that had to be told which it was could be told wrongly. SESSION_REPLACED did not come easily, and what it cost is worth keeping. The obvious provocation — replace the session, then open a fresh flow on the older carrier — does not work: this Portal implements replacement by tearing the older carrier down, so the older connection is draining before it can ask anything and the answer is ERR_DRAINING rather than a setup byte. The shape that works is the specification's own. OPEN arrives, the session is replaced, ATTACH arrives and is told what became of it — section 6's "an OPEN-side rejection is retained long enough to return the same result when ATTACH arrives". The downlink is a TLS lane, and it outliving the QUIC carrier it was paired with is what makes the byte observable at all. PAIR_TIMEOUT runs against a Portal with NOW_FLOW_PAIR_TIMEOUT=2s. The default is fifteen, which is a long time to hold a suite waiting for a deadline. Row 25's standing entry is settled rather than left open. Five of the eight SetupResult values are now observed from a real Portal. Three remain constructed and are named as such: FLOW_LIMIT, because this client refuses its own 257th stream and the Portal is never asked; INVALID_REQUEST and INTERNAL_ERROR, because a Portal answering either would be reporting a fault, which is not something a conformant client can ask for. L3: twenty-three covered, one partly, three blocked. The three left are the carrier-combination differential and the two rows that need physical hardware. Gates green; device suite 49 cases.
1 parent cdc1547 commit 93bc59e

7 files changed

Lines changed: 845 additions & 11 deletions

File tree

Lines changed: 348 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,348 @@
1+
// SPDX-License-Identifier: GPL-3.0-only
2+
// Copyright (C) 2026 The Somewhere Authors
3+
4+
package eu.nodepass.somewhere.quic
5+
6+
import androidx.test.ext.junit.runners.AndroidJUnit4
7+
import eu.nodepass.somewhere.net.NowhereDialer
8+
import eu.nodepass.somewhere.protocol.DecodeResult
9+
import eu.nodepass.somewhere.protocol.auth.AuthTransport
10+
import eu.nodepass.somewhere.protocol.auth.Authentication
11+
import eu.nodepass.somewhere.protocol.frame.FlowCarrier
12+
import eu.nodepass.somewhere.protocol.frame.FlowHeader
13+
import eu.nodepass.somewhere.protocol.frame.FlowKind
14+
import eu.nodepass.somewhere.protocol.frame.FlowRole
15+
import eu.nodepass.somewhere.protocol.frame.SetupResult
16+
import eu.nodepass.somewhere.protocol.session.NowhereSession
17+
import eu.nodepass.somewhere.protocol.session.SessionId
18+
import eu.nodepass.somewhere.protocol.session.SplitCarrier
19+
import eu.nodepass.somewhere.protocol.session.Transport
20+
import eu.nodepass.somewhere.protocol.target.Target
21+
import eu.nodepass.somewhere.protocol.url.NextHopCarrier
22+
import eu.nodepass.somewhere.protocol.url.NowhereUrl
23+
import eu.nodepass.somewhere.vpn.E2eEnvironment
24+
import org.junit.Assert.assertEquals
25+
import org.junit.Assert.assertNotNull
26+
import org.junit.Assert.assertTrue
27+
import org.junit.Test
28+
import org.junit.runner.RunWith
29+
import java.net.InetSocketAddress
30+
31+
/**
32+
* Split flows, and the three rejections that only they can provoke.
33+
*
34+
* ## What was unreachable before this
35+
*
36+
* `PAIR_TIMEOUT`, `METADATA_CONFLICT` and `SESSION_REPLACED` are three of the
37+
* seven `SetupResult` values, and the L1 coverage map has said "unreachable at
38+
* this layer" about them since it was written. They are unreachable because
39+
* each is a Portal's answer to something only two lanes can express: a half
40+
* that never arrived, two halves that disagree, and a session claimed twice.
41+
* Their *messages* have been tested since L1. Their *provocation* needs this.
42+
*
43+
* The Portal these run against is configured with a two-second pairing timeout
44+
* (`NOW_FLOW_PAIR_TIMEOUT=2s`); the default is fifteen, which is a long time to
45+
* hold a test suite waiting for a deadline to pass.
46+
*/
47+
@RunWith(AndroidJUnit4::class)
48+
class QuicSplitFlowTest {
49+
@Test
50+
fun aSplitFlowCarriesPayloadWithTheUplinkOnQuic() {
51+
withSplitSession(up = NextHopCarrier.Udp, down = NextHopCarrier.Tcp) { session ->
52+
assertEquals("the echo did not survive the split flow", ECHO, roundTrip(session))
53+
}
54+
}
55+
56+
@Test
57+
fun aSplitFlowCarriesPayloadWithTheUplinkOnTls() {
58+
withSplitSession(up = NextHopCarrier.Tcp, down = NextHopCarrier.Udp) { session ->
59+
assertEquals("the echo did not survive the split flow", ECHO, roundTrip(session))
60+
}
61+
}
62+
63+
/**
64+
* `PAIR_TIMEOUT`: the matching half arrived too late.
65+
*
66+
* Provoked by sending OPEN, waiting past the Portal's pairing deadline, and
67+
* only then sending ATTACH. The result cannot be delivered before ATTACH
68+
* exists — the uplink is a lane the client writes and never reads — so the
69+
* rejection is what ATTACH receives rather than something OPEN is told.
70+
*/
71+
@Test
72+
fun pairTimeoutIsProvokedByAnAttachThatArrivesTooLate() {
73+
assertEquals(
74+
SetupResult.PairTimeout,
75+
rejectionFrom { lanes ->
76+
lanes.sendOpen()
77+
Thread.sleep(PAIR_WAIT)
78+
lanes.sendAttach()
79+
},
80+
)
81+
}
82+
83+
/** `METADATA_CONFLICT`: the two halves disagree about what the flow is. */
84+
@Test
85+
fun metadataConflictIsProvokedByHalvesThatDisagreeAboutKind() {
86+
assertEquals(
87+
SetupResult.MetadataConflict,
88+
rejectionFrom { lanes ->
89+
lanes.sendOpen(kind = FlowKind.Tcp)
90+
lanes.sendAttach(kind = FlowKind.Udp)
91+
},
92+
)
93+
}
94+
95+
/**
96+
* `SESSION_REPLACED`: the session state this flow was waiting on is gone.
97+
*
98+
* The first shape tried was to replace the session and then open a fresh
99+
* flow on the older carrier. That does not work, and the reason is worth
100+
* keeping: **this Portal implements replacement by tearing the older
101+
* carrier down**, so the older connection is draining before it can ask
102+
* anything and the answer is `ERR_DRAINING` rather than a setup byte.
103+
*
104+
* The shape that does work is the one the specification describes: OPEN
105+
* arrives, the session it belongs to is replaced, and then ATTACH arrives
106+
* and is told what became of it. Section 6: *"An OPEN-side rejection is
107+
* retained long enough to return the same result when ATTACH arrives."*
108+
* The downlink here is a TLS lane, which outlives the QUIC carrier it was
109+
* paired with — that independence is what makes the byte observable at all.
110+
*/
111+
@Test
112+
fun sessionReplacedIsReturnedToAnAttachWhoseSessionWasTakenOver() {
113+
val portal = E2eEnvironment.requirePortal()
114+
val url = node(portal, NextHopCarrier.Udp, NextHopCarrier.Tcp)
115+
val sessionId = SessionId.random()
116+
val dialer = NowhereDialer(protect = { true })
117+
118+
connection(portal, url).use { first ->
119+
first.completeHandshake()
120+
121+
// OPEN, on a carrier that is about to be replaced. The downlink is
122+
// a separate TLS connection and survives what happens to the QUIC
123+
// one.
124+
val up = QuicStreamTransport(first, first.openStream())
125+
val down = (dialer.connect(url) as DecodeResult.Ok).value
126+
val orphaned = Lanes(up, down, sessionId, flowId = 21u)
127+
orphaned.sendOpen()
128+
129+
connection(portal, url).use { second ->
130+
second.completeHandshake()
131+
val takeoverUp = QuicStreamTransport(second, second.openStream())
132+
val takeoverDown = (dialer.connect(url) as DecodeResult.Ok).value
133+
val takeover = Lanes(takeoverUp, takeoverDown, sessionId, flowId = 22u)
134+
takeover.sendOpen()
135+
takeover.sendAttach()
136+
assertNotNull("the replacing session never authenticated", takeover.setupByte())
137+
138+
// Now the orphaned half asks what became of its flow.
139+
orphaned.sendAttach()
140+
val setup = orphaned.setupByte()
141+
assertNotNull("the orphaned ATTACH was answered with silence", setup)
142+
assertEquals(
143+
SetupResult.SessionReplaced,
144+
(SetupResult.decode(setup!!) as DecodeResult.Ok).value,
145+
)
146+
runCatching { takeoverUp.close() }
147+
runCatching { takeoverDown.close() }
148+
}
149+
runCatching { up.close() }
150+
runCatching { down.close() }
151+
}
152+
}
153+
154+
/**
155+
* Row 7: the result arrives on the downlink and nowhere else.
156+
*
157+
* Not an ordering detail. The uplink is a lane the client writes and never
158+
* reads, so a setup byte placed there would be a byte nobody collects — and
159+
* a client that read one would be reading the first byte of payload.
160+
*/
161+
@Test
162+
fun onlyTheDownlinkReceivesTheResult() {
163+
val portal = E2eEnvironment.requirePortal()
164+
val url = node(portal, NextHopCarrier.Udp, NextHopCarrier.Tcp)
165+
connection(portal, url).use { quic ->
166+
quic.completeHandshake()
167+
val dialer = NowhereDialer(protect = { true })
168+
val up = QuicStreamTransport(quic, quic.openStream())
169+
val down = (dialer.connect(url) as DecodeResult.Ok).value
170+
val lanes = Lanes(up, down, SessionId.random(), flowId = 9u)
171+
try {
172+
lanes.sendOpen()
173+
lanes.sendAttach()
174+
val setup = lanes.setupByte()
175+
assertNotNull("the downlink was not answered", setup)
176+
assertEquals(SetupResult.Ready, (SetupResult.decode(setup!!) as DecodeResult.Ok).value)
177+
178+
// The uplink carries the request onward and answers nothing.
179+
up.setReadTimeout(UPLINK_SILENCE_MILLIS)
180+
val buffer = ByteArray(1)
181+
val read = runCatching { up.read(buffer, 0, 1) }.getOrDefault(-1)
182+
assertTrue("the uplink answered $read byte(s); only the downlink may", read <= 0)
183+
} finally {
184+
runCatching { up.close() }
185+
runCatching { down.close() }
186+
}
187+
}
188+
}
189+
190+
// ── plumbing ────────────────────────────────────────────────────────────
191+
192+
/** The two lanes of one split flow, driven by hand. */
193+
private inner class Lanes(
194+
val up: Transport,
195+
val down: Transport,
196+
val sessionId: SessionId,
197+
val flowId: UInt,
198+
) {
199+
fun sendOpen(kind: FlowKind = FlowKind.Tcp) {
200+
val header =
201+
(
202+
FlowHeader.forClient(FlowRole.Open, kind, FlowCarrier.Quic, FlowCarrier.TlsTcp, flowId)
203+
as DecodeResult.Ok
204+
).value
205+
up.write(auth(up) + header.encode() + echoTarget().encode())
206+
up.flush()
207+
}
208+
209+
fun sendAttach(kind: FlowKind = FlowKind.Tcp) {
210+
val header =
211+
(
212+
FlowHeader.forClient(FlowRole.Attach, kind, FlowCarrier.Quic, FlowCarrier.TlsTcp, flowId)
213+
as DecodeResult.Ok
214+
).value
215+
down.write(auth(down) + header.encode())
216+
down.flush()
217+
}
218+
219+
fun setupByte(): Byte? {
220+
val buffer = ByteArray(1)
221+
return try {
222+
if (down.read(buffer, 0, 1) == 1) buffer[0] else null
223+
} catch (_: java.io.IOException) {
224+
null
225+
}
226+
}
227+
228+
private fun auth(transport: Transport): ByteArray =
229+
Authentication.encodeFrame(
230+
sharedKey = key(),
231+
transport =
232+
if (transport.transportKind == eu.nodepass.somewhere.protocol.session.TransportKind.Quic) {
233+
AuthTransport.Quic
234+
} else {
235+
AuthTransport.TlsTcp
236+
},
237+
exporter = transport.exporter,
238+
sessionId = sessionId.toByteArray(),
239+
)
240+
}
241+
242+
private fun rejectionFrom(drive: (Lanes) -> Unit): SetupResult {
243+
val portal = E2eEnvironment.requirePortal()
244+
val url = node(portal, NextHopCarrier.Udp, NextHopCarrier.Tcp)
245+
connection(portal, url).use { quic ->
246+
quic.completeHandshake()
247+
val dialer = NowhereDialer(protect = { true })
248+
val up = QuicStreamTransport(quic, quic.openStream())
249+
val down = (dialer.connect(url) as DecodeResult.Ok).value
250+
val lanes = Lanes(up, down, SessionId.random(), flowId = 1u)
251+
try {
252+
drive(lanes)
253+
val setup = lanes.setupByte() ?: throw AssertionError("the Portal answered nothing")
254+
return (SetupResult.decode(setup) as DecodeResult.Ok).value
255+
} finally {
256+
runCatching { up.close() }
257+
runCatching { down.close() }
258+
}
259+
}
260+
}
261+
262+
private fun roundTrip(session: NowhereSession): String {
263+
val request = "GET /small.bin HTTP/1.0\r\n\r\n".toByteArray(Charsets.US_ASCII)
264+
val opened = session.openFlow(echoTarget(), FlowKind.Tcp, firstPayload = request)
265+
val flow =
266+
(opened as? DecodeResult.Ok)?.value
267+
?: throw AssertionError("the split flow failed: ${opened.reasonOrNull()?.detail}")
268+
flow.use {
269+
val buffer = ByteArray(4096)
270+
val read = flow.read(buffer)
271+
assertTrue("nothing came back on the downlink", read > 0)
272+
return String(buffer, 0, minOf(read, ECHO.length), Charsets.ISO_8859_1)
273+
}
274+
}
275+
276+
private fun withSplitSession(
277+
up: NextHopCarrier,
278+
down: NextHopCarrier,
279+
body: (NowhereSession) -> Unit,
280+
) {
281+
val portal = E2eEnvironment.requirePortal()
282+
val url = node(portal, up, down)
283+
connection(portal, url).use { quic ->
284+
quic.completeHandshake()
285+
val dialer = NowhereDialer(protect = { true })
286+
287+
fun lane(carrier: NextHopCarrier): SplitCarrier.LaneFactory =
288+
when (carrier) {
289+
NextHopCarrier.Udp -> SplitCarrier.LaneFactory { QuicStreamTransport(quic, quic.openStream()) }
290+
NextHopCarrier.Tcp ->
291+
SplitCarrier.LaneFactory { (dialer.connect(url) as DecodeResult.Ok).value }
292+
}
293+
NowhereSession(
294+
sharedKey = url.sharedKey,
295+
connect = { error("a split session dials through its lane factories") },
296+
splitUplink = lane(up),
297+
splitDownlink = lane(down),
298+
).use(body)
299+
}
300+
}
301+
302+
private fun connection(
303+
portal: String,
304+
url: NowhereUrl,
305+
): QuicConnection =
306+
QuicConnection.open(
307+
remote = InetSocketAddress(portal.substringBeforeLast(':'), portal.substringAfterLast(':').toInt()),
308+
alpn = url.alpn,
309+
serverName = null,
310+
protect = { true },
311+
)
312+
313+
private fun node(
314+
portal: String,
315+
up: NextHopCarrier,
316+
down: NextHopCarrier,
317+
): NowhereUrl {
318+
val text = "nowhere://${E2eEnvironment.sharedKey}@$portal?up=${up.token}&down=${down.token}"
319+
return when (val parsed = NowhereUrl.parse(text)) {
320+
is DecodeResult.Ok -> parsed.value
321+
is DecodeResult.Invalid -> throw AssertionError(parsed.reason.detail)
322+
}
323+
}
324+
325+
private fun key() =
326+
(
327+
eu.nodepass.somewhere.protocol.auth.SharedKey
328+
.of(E2eEnvironment.sharedKey) as DecodeResult.Ok
329+
).value
330+
331+
private fun echoTarget(): Target = (Target.ofIpv4(byteArrayOf(127, 0, 0, 1), ORIGIN_PORT) as DecodeResult.Ok).value
332+
333+
private companion object {
334+
const val ORIGIN_PORT = 28091
335+
336+
/**
337+
* The origin answers HTTP/1.1 whatever the request says, so this is
338+
* the reply's own version rather than the one asked for.
339+
*/
340+
const val ECHO = "HTTP/1.1 200"
341+
342+
/** Past the Portal's `NOW_FLOW_PAIR_TIMEOUT=2s`, with room to spare. */
343+
const val PAIR_WAIT = 4_000L
344+
345+
/** Long enough to mean silence, short enough not to stall a suite. */
346+
const val UPLINK_SILENCE_MILLIS = 2_000
347+
}
348+
}

0 commit comments

Comments
 (0)