Skip to content

Release 0.3.0

Latest

Choose a tag to compare

@StevenBtw StevenBtw released this 24 Oct 19:58
3383470

[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 Failed and 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 = 0 and non-integer n, and DISCARD honours n instead of discarding everything.
  • Connections that do not complete the handshake, HELLO and LOGON within 30 s are closed (handshake_timeout, Duration::ZERO disables it).
  • Messages before LOGON are limited to 64 KiB (max_unauthenticated_message_size).
  • ConnectionState::transition_failure returns Defunct for any failure before authentication, and transition_success no longer maps RESET to Ready from Negotiation or Authentication.
  • Client: the handshake fails unless the server answers with one of the proposed versions.
  • ws feature: tokio-tungstenite 0.30 (was 0.26). WsStream::new, ws::server::handle_ws and From<tungstenite::Error> take the 0.30 types, so applications that build the WebSocketStream themselves need tokio-tungstenite 0.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 reached Ready without 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 general max_message_size applies 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 SessionManager and was never closed in the backend. With max_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_sessions rejected 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 lost stats and the bookmark when consuming a partly read result. It now honours n, and its final SUCCESS carries the summary, like PULL.
  • PULL and DISCARD reject n = 0 and non-integer n; only -1 or 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 the Failed state, 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.
  • WsStream treated 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 hints returned by BoltBackend::get_server_info are no longer overwritten in the HELLO response.
  • Client: BoltConnection::send did not flush, so over WebSocket nothing was sent and BoltSession::connect_ws hung.
  • 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_chunks lint (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 to DEFAULT_HANDSHAKE_TIMEOUT (30 s); Duration::ZERO disables it.
  • BoltServer::serve_listener(TcpListener) and BoltServer::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) and DEFAULT_MAX_UNAUTHENTICATED_MESSAGE_SIZE (64 KiB): the message size limit before authentication, capped by max_message_size.
  • Several open results per explicit transaction (Bolt 4.0+ qid): RUN inside a transaction answers with a qid in its SUCCESS, a new RUN is accepted while earlier results are still open, and PULL and DISCARD select a result with {"qid": n} (-1 or no qid means 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, with QueryResult::counters() and Counters::from_summary(): typed write counters read from the summary stats (Neo4j key names, missing keys read as zero) plus contains_updates() and contains_system_updates().
  • boltr::client::QueryResult is now exported (it was returned by BoltSession::run but could not be named).
  • ConnectionState::is_authenticated() and ConnectionState::after_protocol_violation().
  • version::proposals_cover() and packstream::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_server example (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 with uv run): PULL and DISCARD by qid, then COMMIT, and ROLLBACK with results open.

Changed

  • ConnectionState::transition_failure returns Defunct for any failure before authentication, and transition_success no longer maps RESET to Ready from Negotiation or Authentication.
  • Connections that do not authenticate within the handshake timeout (30 s by default) are closed. This also applies to ws::server::accept_ws and ws::server::handle_ws, as does the 64 KiB message limit before authentication (also applied by Connection::new).
  • TxStreaming accepts RUN (another result) and ROLLBACK (which discards the open results); COMMIT is still only accepted once every result is consumed.
  • ChunkWriter::write_message frames the message in memory and writes it with a single write_all (one TLS record per message instead of one write per chunk header), and ChunkReader no 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 to 0.2.0).

Dependencies

  • Raised minimums: tokio 1.44.2 (RUSTSEC-2025-0023), bytes 1.11.1 (RUSTSEC-2026-0007), tracing 0.1.40 (RUSTSEC-2023-0078), rustls-pki-types 1.9 (first release with the pem API used for TLS) and async-trait 0.1.92 (older expansions fail clippy double_must_use on recent Rust). MSRV unchanged (1.91.0).
  • tokio-tungstenite 0.30 (was 0.26, see Breaking).
  • Lockfile refreshed: rustls 0.23.46 and rustls-webpki 0.103.16 clear RUSTSEC-2026-0098, 0099, 0104, 0285, 0346, 0348, 0349 and 0382; tokio 1.54.1 replaces the yanked 1.54.0 (RUSTSEC-2026-0384, a race in tokio::time::Interval). Applications get these through their own lockfile.
  • CI: GitHub Actions updated (actions/checkout v7, codecov/codecov-action v7).

What's Changed

Full Changelog: v0.2.0...v0.3.0