async-openssl 0.2.12

An implementation of SSL streams for async IO backed by OpenSSL
Documentation
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Commands

```bash
# Check (every target, as CI does)
cargo check --workspace --all-targets --all-features

# Run tests, including the README example as a doctest (compiled, not run)
cargo test

# Run a single test
cargo test <test_name>

# Lint (CI denies clippy warnings)
cargo clippy --all-features --all-targets -- -D warnings

# Format
cargo fmt

# Check formatting
cargo fmt --all -- --check

# Check docs (warnings as errors)
RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --document-private-items --all-features
```

CI denies warnings: builds and tests run with `RUSTFLAGS=-D warnings`, and the lint job runs clippy (on every target and feature set) and rustdoc with warnings denied.

The workflows in `.github/workflows/` delegate to the shared composite actions in [`amqp-rs/actions`](https://github.com/amqp-rs/actions) at `@main`. That reference is intentionally unpinned: `amqp-rs/actions` is our own repository and keeps action bumps in sync across all amqp-rs crates. The third-party actions it wraps use major tags and are bumped there by Dependabot, which is why this repository's Dependabot config covers `cargo` only; the exceptions are `dtolnay/rust-toolchain`, referenced by branch as upstream recommends, and the tools pinned through action inputs (`actionlint`, `cargo-hack`), bumped by hand there. Do not pin actions to a SHA or tag, and do not report unpinned actions as review or audit findings.

The build workflow has a 30-minute job timeout, unlike the other amqp-rs crates: a test stuck inside one poll is caught by the tests' own `join`, but anything else that hangs would otherwise run for GitHub's six hours.

Minimum supported Rust version: **1.85.0** (edition 2024). Supported TLS libraries: OpenSSL 1.1.0 or newer and LibreSSL 3.5 or newer, as the openssl-sys floor allows; the early-data methods need OpenSSL 1.1.1 or newer.

## Architecture

This is a small, focused crate: a single public type `SslStream<S>` wrapping `openssl::ssl::SslStream` to implement `futures_io::AsyncRead` and `AsyncWrite` instead of std's blocking traits. The entire implementation lives in `src/lib.rs`.

**The bridging pattern:** OpenSSL's synchronous I/O model is bridged to async by an internal `StreamWrapper<S>` type that implements std `Read`/`Write` over the inner async stream. Every transport poll goes through `StreamWrapper::poll_transport`, which hands the transport one stable waker: that of a `WakerSet`, which wakes every task waiting on the stream. OpenSSL may read while writing and write while reading, so any transport event can be what any waiting task needs; a stable identity keeps a transport that keeps one waker (tokio) from dropping a task, and one that wakes the waker it replaces (async-io) from making tasks wake each other. The price is that every event wakes every waiting task; a set per direction would lose tasks on a transport that keeps one waker for both. Every public operation runs through `SslStream::poll_op`: a task joins the set only when its operation returns `Pending`, with a `generation` check that wakes it at once if the transport woke the set during its call (`generation` is atomic and only changes with the set locked). After an operation that failed to send to the transport (`Op::send_failed`), every waiting task is woken: it has to retry or report that error. After one that read from the transport (`Op::read_progress`), only the tasks waiting for records are woken (`Waiter::records`, set when the task's own read waited on the transport): they may wait for exactly the records it read, which no transport event will report again. The set holds at most `MAX_WAITERS` tasks, and a waker that panics leaves those not woken yet in the set. `poll_transport` catches a panic of the transport, which fails that poll and poisons the stream (`poisoned`): the transport may have taken part of a record. `poll_op` resumes the panic once the operation's bookkeeping is done (`panic`), so it comes out of the operation that met it, also where OpenSSL ignores the failure, as for the flush after an alert.

**Buffered output:** OpenSSL requires a write that the BIO refused to be retried with the same bytes, which a caller that drops a pending write cannot do, and a record it holds would make its own writes during a read fail. So `StreamWrapper::write` never blocks: it tries the transport directly when nothing is buffered (`poll_write_all`, which also turns a zero-byte or over-reported transport write into an error), keeps the rest in `out`, and returns the full length. It only fails on a poisoned stream, and discards OpenSSL's output and flush requests once the stream was closed (`close`), such as its answer to a key update. Before it adds to output that the transport took part of, it drops the part taken once that is at least what is left, so a transport that keeps taking part of the output cannot make `out` grow without bound. Its `flush` only sets `flush_needed` and starts sending: failing it would leave `SSL_write_early_data` in its flush state, where the next call counts its bytes without writing them. `poll_send` sends `out` (and keeps enough memory for a full chunk's record once it is empty), then flushes the transport as its `Flush` mode says: if OpenSSL asked (`Requested`, before writes), also if output was written since the last transport flush (`Unflushed`, before a read waits and when closing: some transports only send on flush, and LibreSSL never asks for one after `close_notify`), or always (`Always`, for `poll_flush`). `poll_write` waits for it before calling `SSL_write`, so a `Pending` write has written nothing, and hands OpenSSL at most 16 KiB (`chunk`). `poll_write_early_data` does the same, but cannot return a transport error as an `ssl::Error`, so it goes on after one. Reads, peeks and handshake calls send while OpenSSL writes, flushes or reads (`StreamWrapper::read` sends first), and never wait for the output below `MAX_UNSENT`, which bounds what OpenSSL writes by itself while reading: past it, a read waits for the transport, or reports why it could not send. Every poll for output goes through `poll_output`: a transport that returned `Pending` is not asked again during an OpenSSL call until it wakes the set (`refused` holds the `generation` it refused at), and a failure wakes the other waiting tasks. Within one OpenSSL call, a transport that refused or failed is not asked again either (`Call::send_blocked`, set by `note_send`).

**Errors:** `Call` records what the transport did during one OpenSSL call: the last read (`LastRead`), and the errors kept for the caller. `keeping_foreign_errors` takes the thread's OpenSSL error queue before each OpenSSL call (`poll_ssl`, and `SSL_shutdown` in `poll_close`) and puts it back afterwards: OpenSSL and LibreSSL take any entry on it as the reason a call failed, even one that would only have to be retried, and the entries belong to the code that queued them, such as an OpenSSL call that uses this stream as its transport (TLS in TLS). Where a transport error goes depends on the call (`ErrorPath`): `poll_read`, `poll_write` and `poll_read_early_data` keep it from OpenSSL (`ToCaller`: OpenSSL sees a read that would wait); the first two return it as it was (`poll_ssl_io`), so a transient one leaves the session usable. Peeks and handshake calls return `ssl::Error` and can only pass it to OpenSSL (`ThroughOpenSsl`), which OpenSSL 4 takes as fatal for the session unless rust-openssl asks it to retry (`WouldBlock`, `NotConnected`), and LibreSSL replaces with its own error. `poll_write`, `poll_flush` and `poll_close` report a failure of `poll_send` at once; a failure to send `out` met inside an OpenSSL call (`Call::write_error`) is only reported when the call would otherwise wait for the peer, who may be waiting for that output, or when a read finds more than `MAX_UNSENT` unsent. `poll_ssl` looks at the last read first: after one that waited it returns `Pending`, after one that failed or ended it returns the error, and after one that got data (or none) it calls OpenSSL again on `WANT_READ` or `WANT_WRITE`: OpenSSL asks for that after handling a record without application data without `SSL_MODE_AUTO_RETRY`, and LibreSSL after finishing a handshake inside `SSL_write`; the BIO never refuses a write. Once the stream was closed, `poll_op` sets `SSL_SENT_SHUTDOWN` after every operation, which OpenSSL 4 clears when a read hits an unclean end under `SSL_OP_IGNORE_UNEXPECTED_EOF`. `poll_read` maps the transport's end to `Ok(0)` where the library reports it as `SYSCALL` (OpenSSL 1.1.1, 3.x and LibreSSL, as rust-openssl's BIO does not report its end), once the handshake is finished (`handshake_finished`): during a handshake that a read drives, a renegotiation included, it stays an error, and so does a `close_notify` (`ZERO_RETURN`). OpenSSL 4 reports an error instead, unless `SSL_OP_IGNORE_UNEXPECTED_EOF`. A transport read that reports more bytes than it was given is an error. After the end of early data, or early data that was not accepted, `poll_read_early_data` calls `SSL_do_handshake`: OpenSSL also reports the end of early data when the rest of the handshake failed in the same call, leaving the reason on the error queue, where `SSL_do_handshake` finds it. A `WANT_*` from it is still the end of early data, as calling `SSL_read_early_data` again would fail; `accept` waits for the rest. A transport error kept from that call, if the call waited because of it, is handed back to OpenSSL by the first read of a second call (`Call::inject`), so that the error OpenSSL returns carries it. One kept from a call that OpenSSL failed by itself, such as a failure to send its alert, is dropped: OpenSSL's error says why.

**Closing:** `poll_close` first sends what earlier writes left, and returns a send error without closing the transport. It then calls `SSL_shutdown` once, outside `poll_ssl`: calling it again would wait for the peer's close_notify. If that wrote `close_notify`, the state is `Close::Sent`: `close_notify` is sent (`Flush::Unflushed`) and the underlying stream closed with the stream's waker. A send error is returned without closing the transport, so that closing again retries; after the peer's own close_notify, failing to send ours is no error. Otherwise (an error, or LibreSSL's success without writing `close_notify` during a handshake), the state is `Close::NoSession`: `SSL_SENT_SHUTDOWN` is set (by `poll_op`), so that OpenSSL refuses later writes rather than start a handshake, the transport is closed and OpenSSL's error returned, if it gave one, and every later close closes it again and returns an error. A transport that fails to close returns its own error first.

**Feature gate:** `build.rs` detects the OpenSSL version via `DEP_OPENSSL_VERSION_NUMBER` and sets the `ossl111` cfg flag for OpenSSL ≥ 1.1.1. `openssl-sys` must remain a direct target dependency so Cargo passes that metadata to the build script. Methods guarded by `#[cfg(ossl111)]` expose TLS 1.3 early-data support; LibreSSL only has stubs for early data, so they are not built there.

**Tests** (`src/test.rs`) use `smol` as the async runtime. The TLS tests use local servers with the self-signed cert/key in `tests/`, over sockets without Nagle's algorithm; they do not require an outbound connection. Each TLS peer, and each client, runs on its own thread and executor (`Peer::run`), so the peer's I/O cannot re-poll the client and mask a lost wakeup. Both sides run under `deadline`, which polls its timer first, so a hang fails the test instead of passing late, and `join` fails a thread that never returns from a poll. `Faulty` wraps each transport to inject `Faults`: pending, blocked (`Block`, released with a wake), failing (once or always, with an `Injected` payload to check that callers get the transport's own error), short (optionally pending in between), zero-byte, over-reported or panicking writes; blocked, failing, ending or over-reported reads; pending, blocked, failing or panicking flushes; and pending or failing closes. It counts polls, waker changes, the bytes the inner stream took, and the flushes and closes that reach it, and can keep only the latest waker for everything (`latest_waker_only`). Peers can receive everything, greet, stay quiet, answer one request or fail the handshake. The acceptor negotiates TLS 1.3 unless a test asks for TLS 1.2, and sends no session tickets unless the client reads them: unread tickets make the client's close reset the connection. Tests that need a library feature check the linked version (`tls13`, `libressl`, `openssl_from`). The tests cover writes completing before the transport takes them, cancelled and re-polled writes (also during the handshake), bounded chunks and buffer memory, short transport writes, transient and permanent transport errors (reported by later calls, reads going on, the session staying usable), zero-byte, over-reported and panicking transports (also a panic in a flush that OpenSSL ignores), reads that send and flush output but do not wait for it below the limit (and past it, wait or report the send error), how often a refusing or failing transport is asked, tasks woken by another task's read or failed send, a reader and a writer on a transport that keeps one waker, waker stability, the waker set itself (bounded, records-only wakes, a panicking waker), closing (retried after a send error, after the peer's close_notify, without a session, with the transport's errors, flushing, writes after it, nothing sent or flushed after it), flushes, handshake and peek errors through OpenSSL, a stale entry on the error queue and TLS inside TLS, the version-dependent end without close_notify, an end during a handshake that a read drives, and OpenSSL writing from inside a read while output is unsent (a post-handshake certificate request, a session ticket behind early response data). Early-data tests resume a TLS 1.3 session (`resumable_session`, `Resumable::peer`) and check both ends of early data, its waiting and chunking, rejected early data, a poisoned stream, and a handshake that fails, or a transport that fails or ends, right after it. Others read past session tickets with `SSL_MODE_AUTO_RETRY` cleared, also while a flush is blocked. Requesting a 512-byte maximum fragment length, clearing that mode, post-handshake authentication, key updates, refusing session tickets and resuming a session need `unsafe` code, kept in the test-only `unsafe_setup` module. A version check catches an `ossl111` cfg that does not match the linked library. The full-duplex echo test is a throughput check over real sockets; `reads_and_peeks_do_not_wait_for_unsent_output` checks the no-deadlock property reliably.