|
| 1 | +package kyo.net.internal.posix |
| 2 | + |
| 3 | +import kyo.* |
| 4 | +import kyo.ffi.Buffer |
| 5 | +import kyo.ffi.Ffi |
| 6 | +import kyo.net.NetConnectionIoException |
| 7 | +import kyo.net.Test |
| 8 | +import kyo.net.internal.TlsEngineLoopback |
| 9 | +import kyo.net.internal.TlsRealEngines |
| 10 | +import kyo.net.internal.transport.ReadOutcome |
| 11 | + |
| 12 | +/** Poller-path fatal-record parity guard, the epoll/kqueue sibling of [[IoUringDriverCorruptRecordTest]]. |
| 13 | + * |
| 14 | + * A fatal record-layer error (RFC 5246 §7.2.2) must terminate the connection surfaced as the typed decrypt failure |
| 15 | + * `ReadOutcome.Failed(NetConnectionIoException(Decrypt))`, identical to the io_uring driver, never a bare `Closed` and never the good |
| 16 | + * prefix delivered behind the corruption. On BoringSSL a fatal record is signalled by `feedAndDecrypt`'s `onFatal` (a `readPlain == -2` |
| 17 | + * return, not a thrown exception), so `dispatchReadTls` must complete the typed decrypt failure on that path directly: `onFatal`'s |
| 18 | + * `requestClose` marks the handle closing, and the read completion is what distinguishes a corrupt record (a typed decrypt failure) from an |
| 19 | + * ordinary local close (`Closed`). |
| 20 | + * |
| 21 | + * The leaf feeds `[good record][corrupted record]` (the corruption flips the last body byte of the second record so its AEAD tag fails) |
| 22 | + * coalesced in one wire write, so both records arrive in a single recv, and reads once via the driver. It asserts the read fails with the |
| 23 | + * typed decrypt failure, the handle is closing, and neither the good prefix nor a bare `Closed` is delivered. |
| 24 | + * |
| 25 | + * Gate: [[PosixTestSockets.assumePoller]] (Linux epoll or macOS kqueue) and [[TlsRealEngines.assumeTlsReady]] (a staged BoringSSL/OpenSSL |
| 26 | + * provider); cancels cleanly on JS, off a poller platform, or where no provider is staged, so this is CI-validated on native Linux with |
| 27 | + * BoringSSL staged. |
| 28 | + * |
| 29 | + * Anti-flakiness: the read synchronizes on the read promise (completed only when the recv edge dispatches and the decrypt engine op runs), |
| 30 | + * never a timer; `Async.timeout` is only the deadlock ceiling. The settle-wait on `tls` going Absent lets the driver's own queued TLS |
| 31 | + * teardown run its engine free before `withEngines` frees the same engine out of band, so that becomes a harmless CAS-guarded second free. |
| 32 | + * No sleep, no busy-spin. |
| 33 | + */ |
| 34 | +class PollerIoDriverCorruptRecordTest extends Test: |
| 35 | + |
| 36 | + import AllowUnsafe.embrace.danger |
| 37 | + |
| 38 | + private def sock = Ffi.load[SocketBindings] |
| 39 | + |
| 40 | + /** Poll a real condition until it holds or the bound elapses, re-checking each turn after a short Async.sleep. */ |
| 41 | + private def awaitCondition(bound: Duration)(cond: => Boolean)(using Frame): Boolean < Async = |
| 42 | + val deadline = java.lang.System.nanoTime() + bound.toNanos |
| 43 | + Loop(()) { _ => |
| 44 | + if cond then Loop.done(true) |
| 45 | + else if java.lang.System.nanoTime() >= deadline then Loop.done(false) |
| 46 | + else Async.sleep(2.millis).andThen(Loop.continue(())) |
| 47 | + } |
| 48 | + end awaitCondition |
| 49 | + |
| 50 | + "PollerIoDriver corrupt-record (real epoll/kqueue, real engine)" - { |
| 51 | + |
| 52 | + "a fatal TLS record on a read fails the read with a typed decrypt error and tears the connection down, never delivering the good prefix" in { |
| 53 | + if kyo.internal.Platform.isJS then Sync.defer(succeed) |
| 54 | + else |
| 55 | + TlsRealEngines.assumeTlsReady() |
| 56 | + PosixTestSockets.assumePoller() |
| 57 | + TlsRealEngines.withEngines { (clientEngine, serverEngine) => |
| 58 | + val driver = PollerIoDriver.init() |
| 59 | + discard(driver.start()) |
| 60 | + Sync.ensure(Sync.defer(driver.close())) { |
| 61 | + PosixTestSockets.loopbackPair().map { case (client, accepted) => |
| 62 | + val handshakeDone = TlsEngineLoopback.handshake(clientEngine, serverEngine) |
| 63 | + assert(handshakeDone, "TLS handshake must complete before the read") |
| 64 | + val acceptedH = PosixHandle.socket(accepted, PosixHandle.DefaultReadBufferSize, Absent, Frame.internal) |
| 65 | + acceptedH.tls = Present(serverEngine) |
| 66 | + |
| 67 | + val good = "GOOD-application-record".getBytes("UTF-8") |
| 68 | + val bad = "TAMPERED-application-record".getBytes("UTF-8") |
| 69 | + // One writePlain per record yields one TLS record each. |
| 70 | + val goodRecord = TlsEngineLoopback.encrypt(clientEngine, good) |
| 71 | + val badRecord = TlsEngineLoopback.encrypt(clientEngine, bad) |
| 72 | + assert( |
| 73 | + goodRecord.length > 5 && badRecord.length > 5, |
| 74 | + "expected real TLS records with a 5-byte header plus body" |
| 75 | + ) |
| 76 | + // Corrupt the body of the SECOND record (skip the 5-byte header) so its AEAD tag fails; the first stays intact. |
| 77 | + val corrupted = badRecord.clone() |
| 78 | + corrupted(corrupted.length - 1) = (corrupted(corrupted.length - 1) ^ 0xff).toByte |
| 79 | + // Coalesce [good record][corrupted record] in one wire write, exactly the on-wire batching the driver sees under load. |
| 80 | + val coalesced = new Array[Byte](goodRecord.length + corrupted.length) |
| 81 | + java.lang.System.arraycopy(goodRecord, 0, coalesced, 0, goodRecord.length) |
| 82 | + java.lang.System.arraycopy(corrupted, 0, coalesced, goodRecord.length, corrupted.length) |
| 83 | + // Send BEFORE arming the read so both records are in the accepted side's kernel buffer, delivered in one recv. |
| 84 | + val cipherBuf = Buffer.fromArray[Byte](coalesced) |
| 85 | + val sendR = |
| 86 | + try sock.sendNow(client, cipherBuf, coalesced.length.toLong, PosixConstants.MSG_NOSIGNAL) |
| 87 | + finally cipherBuf.close() |
| 88 | + assert(sendR.value.toInt == coalesced.length, s"send failed: errno=${sendR.errorCode}") |
| 89 | + |
| 90 | + val promise = Promise.Unsafe.init[ReadOutcome, Abort[Closed]]() |
| 91 | + driver.awaitRead(acceptedH, promise) |
| 92 | + Abort.run[Timeout | Closed](Async.timeout(5.seconds)(promise.safe.get)).map { outcome => |
| 93 | + // requestClose fired inside the fatal completion (poll carrier), which happens-before this outcome, so isClosing is |
| 94 | + // already settled here. Capture it before our own closeHandle below. |
| 95 | + val closing = acceptedH.isClosing() |
| 96 | + driver.closeHandle(acceptedH) |
| 97 | + discard(sock.close(client)) |
| 98 | + // Let the driver's queued TLS teardown run its own engine free before withEngines frees the same engine out of band. |
| 99 | + awaitCondition(5.seconds)(!acceptedH.tls.isDefined).map { settled => |
| 100 | + assert( |
| 101 | + settled, |
| 102 | + "the driver's own TLS teardown never settled (a hang, not the fatal-record path this guard targets)" |
| 103 | + ) |
| 104 | + outcome match |
| 105 | + case Result.Success(ReadOutcome.Failed(e: NetConnectionIoException)) => |
| 106 | + // RFC 5246 §7.2.2: the fatal record tears the connection down, surfaced as the typed decrypt failure |
| 107 | + // (never a misleading Closed, and never the good prefix). The handle must be closing, not re-armed. |
| 108 | + assert( |
| 109 | + e.operation == NetConnectionIoException.Operation.Decrypt, |
| 110 | + s"the fatal record must surface as a TLS decrypt failure; got operation ${e.operation}" |
| 111 | + ) |
| 112 | + assert( |
| 113 | + closing, |
| 114 | + "the fatal record must mark the handle closing (requestClose), tearing it down rather than re-arming a freed handle" |
| 115 | + ) |
| 116 | + case Result.Success(ReadOutcome.Bytes(got)) => |
| 117 | + fail( |
| 118 | + s"a fatal TLS record was swallowed: the read delivered ${got.size} bytes (${got.toArray.toList}) " + |
| 119 | + "instead of the typed decrypt failure; RFC 5246 §7.2.2 requires the connection to be torn down" |
| 120 | + ) |
| 121 | + case Result.Failure(_: Closed) => |
| 122 | + fail( |
| 123 | + "a fatal TLS record surfaced as a bare Closed instead of the typed decrypt failure: the fatal " + |
| 124 | + "path fell through to the endDispatch closing check. io_uring and the poller must agree" |
| 125 | + ) |
| 126 | + case Result.Failure(_: Timeout) => |
| 127 | + fail( |
| 128 | + "the read hung on a fatal record: the fatal path did not complete the read (a torn-down handle was re-armed)" |
| 129 | + ) |
| 130 | + case other => |
| 131 | + fail(s"a fatal TLS record must surface as the typed decrypt failure; got: $other") |
| 132 | + end match |
| 133 | + } |
| 134 | + } |
| 135 | + } |
| 136 | + } |
| 137 | + } |
| 138 | + } |
| 139 | + } |
| 140 | + |
| 141 | +end PollerIoDriverCorruptRecordTest |
0 commit comments