Repository navigation
[0.3.0] - 2026-10-24
Breaking
Behavior that servers and clients built on 0.2 will notice. Each item is described in full below.
- Any failure before authentication (a failed HELLO or LOGON, or an undecodable message) closes the connection. Before, the connection went to
Failedand a RESET recovered it. - Messages that are not valid in the current state are answered with FAILURE instead of IGNORED.
- PULL and DISCARD reject
n = 0and non-integern, and DISCARD honoursninstead of discarding everything. - Connections that do not complete the handshake, HELLO and LOGON within 30 s are closed (
handshake_timeout,Duration::ZEROdisables it). - Messages before LOGON are limited to 64 KiB (
max_unauthenticated_message_size). ConnectionState::transition_failurereturnsDefunctfor any failure before authentication, andtransition_successno longer maps RESET toReadyfromNegotiationorAuthentication.- Client: the handshake fails unless the server answers with one of the proposed versions.
wsfeature:tokio-tungstenite0.30 (was 0.26).WsStream::new,ws::server::handle_wsandFrom<tungstenite::Error>take the 0.30 types, so applications that build theWebSocketStreamthemselves needtokio-tungstenite0.30 too.
Security
- Authentication bypass: a failed LOGON (or a failed HELLO, or an undecodable message before LOGON) moved the connection to
Failed, from which RESET reachedReadywithout authenticating, so a client with wrong credentials could run queries. Any failure before authentication now closes the connection (Defunct), as the Bolt specification requires, and the connection also tracks on its own whether LOGON succeeded before dispatching any other message. - Process abort on deeply nested values: PackStream decoding recursed without a limit, so one message of nested one-element lists overflowed the stack and aborted the whole server. Nesting is now limited to
packstream::decode::MAX_NESTING_DEPTH(128) levels. - Memory exhaustion before authentication: a decoded value takes about 170 bytes per one-byte PackStream value, so a single 16 MiB HELLO could make the server allocate gigabytes before the client authenticated (a 1 MiB HELLO measured 254 MiB of peak heap). Messages before LOGON (and after LOGOFF) are now limited to
max_unauthenticated_message_size(64 KiB by default, a few tens of MB decoded at worst); the generalmax_message_sizeapplies once authenticated.
Fixed
- Session leak on disconnect: when a write failed (client gone mid-response), the connection returned before its cleanup, so the session stayed registered in the
SessionManagerand was never closed in the backend. Withmax_sessions, a few such disconnects locked every client out. Cleanup (roll back an open transaction, close the backend session) now runs however the connection ends. - Backend session leak when
max_sessionsrejected a HELLO: the session created by the backend was never closed. - DISCARD ignored
n(it always discarded everything) and dropped the result summary, so drivers loststatsand the bookmark when consuming a partly read result. It now honoursn, and its final SUCCESS carries the summary, like PULL. - PULL and DISCARD reject
n = 0and non-integern; only-1or a positive count is valid. - Messages that are not valid in the current state are answered with FAILURE (
Neo.ClientError.Request.Invalid) instead of IGNORED. IGNORED is only sent in theFailedstate, as specified. - Only RUN refreshed a session's idle timer, so a client paging through a result was reaped while active. Every message now counts as activity.
idle_timeout(Duration::ZERO)panicked the idle reaper.- PackStream structure field counts were not validated, so a structure declaring too few fields silently consumed the following values of the message.
- Declared list and dictionary sizes were reserved up front (bounded only by the message size times the size of a
BoltValue); pre-allocation is now capped and containers grow as elements are decoded. - Decode errors embedded the offending value, so a malformed multi-megabyte value produced a multi-megabyte error message (echoed back in FAILURE). They now name the value type only.
- Message decoding accepted any first byte as a structure marker.
- The server closed connections while client input was still unread, which makes the operating system send a reset that can discard the final FAILURE before the client reads it (drivers then report a retryable connection error instead of, for example, an authentication error). The server now shuts down its write side and drains input for up to a second before closing.
- An oversized message is now answered with a FAILURE before the connection closes.
WsStreamtreated an empty binary WebSocket frame as the end of the stream.- The accept loop spun at full CPU on persistent
accept()errors (for example when out of file descriptors); it now backs off for 100 ms. - Metadata
hintsreturned byBoltBackend::get_server_infoare no longer overwritten in the HELLO response. - Client:
BoltConnection::senddid not flush, so over WebSocket nothing was sent andBoltSession::connect_wshung. - Client: NOOP keep-alive chunks, which Neo4j sends during long-running queries, were decoded as messages and failed.
- Client: the handshake accepted any server answer as the negotiated version; it now requires one of the proposed versions, so connecting to a non-Bolt port fails immediately with a clear error.
- Clippy
chunks_exact_to_as_chunkslint (Rust 1.98) in version negotiation.
Added
BoltServer::handshake_timeout(Duration): a new connection must complete the TLS and WebSocket handshakes (when enabled), the Bolt handshake, HELLO and LOGON within this time or it is closed. Defaults toDEFAULT_HANDSHAKE_TIMEOUT(30 s);Duration::ZEROdisables it.BoltServer::serve_listener(TcpListener)andBoltServer::ws_serve_listener(TcpListener)to serve an already bound listener (for example one bound to port 0 in tests).BoltServer::max_unauthenticated_message_size(bytes)andDEFAULT_MAX_UNAUTHENTICATED_MESSAGE_SIZE(64 KiB): the message size limit before authentication, capped bymax_message_size.- Several open results per explicit transaction (Bolt 4.0+
qid): RUN inside a transaction answers with aqidin its SUCCESS, a new RUN is accepted while earlier results are still open, and PULL and DISCARD select a result with{"qid": n}(-1or noqidmeans the most recent RUN). Drivers rely on this once a result in a transaction exceeds their fetch size. No backend change is needed: results are buffered by the server framework (at most 1024 open per transaction). boltr::client::Counters, withQueryResult::counters()andCounters::from_summary(): typed write counters read from the summarystats(Neo4j key names, missing keys read as zero) pluscontains_updates()andcontains_system_updates().boltr::client::QueryResultis now exported (it was returned byBoltSession::runbut could not be named).ConnectionState::is_authenticated()andConnectionState::after_protocol_violation().version::proposals_cover()andpackstream::decode::MAX_NESTING_DEPTH.- Tests: PackStream property tests (proptest) over every value type and message, exact wire bytes at every integer and size boundary, chunk framing at the 65535 byte limit, state machine transitions for every state, and end-to-end tests of full sessions over TCP, WebSocket and TLS (certificate generated with rcgen), including interleaved results by
qid, malformed input, disconnects, timeouts and the regressions above. A counting allocator checks the memory an unauthenticated client can cause. - A cargo-fuzz harness in
fuzz/(PackStream values, messages, server connection). It is not run in CI. - The
driver_serverexample (a minimal backend for testing official drivers) and a CI job that reads several open results in one transaction past the fetch size with the neo4j Python driver 6.1.0 (tests/drivers/python/multiple_results.py, run withuv run): PULL and DISCARD byqid, then COMMIT, and ROLLBACK with results open.
Changed
ConnectionState::transition_failurereturnsDefunctfor any failure before authentication, andtransition_successno longer maps RESET toReadyfromNegotiationorAuthentication.- Connections that do not authenticate within the handshake timeout (30 s by default) are closed. This also applies to
ws::server::accept_wsandws::server::handle_ws, as does the 64 KiB message limit before authentication (also applied byConnection::new). TxStreamingaccepts RUN (another result) and ROLLBACK (which discards the open results); COMMIT is still only accepted once every result is consumed.ChunkWriter::write_messageframes the message in memory and writes it with a singlewrite_all(one TLS record per message instead of one write per chunk header), andChunkReaderno longer holds a 64 KiB buffer per connection.- The server moves records out of the buffered result on PULL instead of cloning them.
- The client HELLO user agent is
boltr-client/<crate version>(it was hard-coded to0.2.0).
Dependencies
- Raised minimums:
tokio1.44.2 (RUSTSEC-2025-0023),bytes1.11.1 (RUSTSEC-2026-0007),tracing0.1.40 (RUSTSEC-2023-0078),rustls-pki-types1.9 (first release with thepemAPI used for TLS) andasync-trait0.1.92 (older expansions fail clippydouble_must_useon recent Rust). MSRV unchanged (1.91.0). tokio-tungstenite0.30 (was 0.26, see Breaking).- Lockfile refreshed:
rustls0.23.46 andrustls-webpki0.103.16 clear RUSTSEC-2026-0098, 0099, 0104, 0285, 0346, 0348, 0349 and 0382;tokio1.54.1 replaces the yanked 1.54.0 (RUSTSEC-2026-0384, a race intokio::time::Interval). Applications get these through their own lockfile. - CI: GitHub Actions updated (
actions/checkoutv7,codecov/codecov-actionv7).
What's Changed
- Release/0.3.0 by @StevenBtw in #7
Full Changelog: v0.2.0...v0.3.0