Skip to content

no_std / no_alloc support: zero-copy protocol core (0.2.0)#3

Open
JustinKovacich wants to merge 17 commits into
mainfrom
no-std-support
Open

no_std / no_alloc support: zero-copy protocol core (0.2.0)#3
JustinKovacich wants to merge 17 commits into
mainfrom
no-std-support

Conversation

@JustinKovacich

Copy link
Copy Markdown

Summary

Converts the DoIP protocol core to compile on bare-metal targets (#![no_std], no alloc) while keeping the tokio client/server behind opt-in features. Version 0.1.0 → 0.2.0 (breaking).

  • Protocol core (src/messages/, src/framer.rs): new Encode (into embedded_io::Write) and zero-copy Decode<'a> traits replace the std::io + byteorder read/write; Message<D> is generic over its data container (MessageRef<'a> borrowed / OwnedMessage alloc); sans-io try_frame() extracts one message from an RX buffer.
  • Features: lean default = []; alloc / std / codec / client / server opt-in (documented in README). Bare metal uses default-features = false.
  • std layer: MessageCodec wraps try_frame; client/server ported to OwnedMessage with no behavioral changes beyond those listed below.
  • CI: full verification matrix incl. thumbv7em-none-eabihf and per-feature checks; pedantic clippy now green.
  • Example: examples/bare_metal_codec.rs exercises encode + incremental framing with stack buffers only.

Intentional behavior changes

  • routing_activation_request now decodes the optional 4-byte manufacturer tail (was hardcoded None).
  • A corrupt (full) header now surfaces an error instead of stalling the codec forever; the server logs and closes that connection instead of panicking.
  • Hardening from final review: all todo!() panics reachable from remote input replaced with errors or real implementations (VehicleAnnouncement now carries its VehicleIdentificationResponse payload); routing_activation_response header length derived from the payload (was hardcoded 9, wrong when oem_specific is Some); try_frame length arithmetic is overflow-safe on 32-bit targets; server-only builds compile; socket error logs keep full OS error detail via a std-gated MessageError::Std variant; active_connections uses an RAII guard so early returns can't leak slots.

Verification

  • Adversarial review gates from the migration plan (docs/planning/no_std_migration_plan.md): Gate A (wire-format equivalence, 34/34 golden vectors) and Gate B (std-layer behavior preservation) passed; final whole-branch review findings fixed as above.
  • Full matrix green: check/test with no features, alloc, std, codec, client, server, client,server, thumbv7em target; clippy -D warnings + pedantic; fmt.

Known follow-ups (out of scope)

  • server.rs accept-loop panic! on TCP accept failure (pre-existing TODO).
  • tests/integration_test.rs is an empty stub (pre-existing).
  • Codec encode path stages through an intermediate Vec (simplest-correct version per plan; a BufMut writer adapter would remove a copy).

🤖 Generated with Claude Code

JustinKovacich and others added 17 commits July 6, 2026 14:31
Convert the DoIP protocol core to compile on bare-metal targets
(no_std, no alloc) with tokio client/server behind opt-in features.

- Cargo.toml: lean `default = []`; std/alloc/codec/client/server are
  opt-in. Drop byteorder; embedded-io replaces std::io on the core path.
  Version 0.1.0 -> 0.2.0 (breaking).
- lib.rs: #![no_std], gate the tokio modules, core::time::Duration.
- messages/: new Encode (into embedded_io::Write) and zero-copy
  Decode<'a> (borrows from the RX buffer) traits replace read/write.
  DiagnosticMessage/Ack and Message/Payload are now generic over the
  data container (MessageRef<'a> = borrowed, OwnedMessage = alloc).
- framer.rs: sans-io try_frame() extracts one message from a buffer.

Two intentional wire-behavior changes vs 0.1.0 (Gate A verified these
are the ONLY divergences; 34/34 golden vectors otherwise byte-identical):
- routing_activation_request now decodes the optional 4-byte
  manufacturer tail (previously hardcoded to None).
- try_frame errors on a corrupt/short header instead of stalling
  (returning Ok(None) forever).

Checkpoints pass: cargo check --no-default-features (+alloc, +thumbv7em
target) and cargo test --no-default-features. Gate A adversarial review:
PASS, no confirmed correctness findings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The work-package plan (WP0-WP6) with adversarial review gates that
drove the 0.2.0 no_std conversion.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Rewrite MessageCodec::decode to wrap the sans-io crate::try_frame() and
convert the borrowed MessageRef into an OwnedMessage; rewrite
MessageCodec::encode to buffer through a Vec<u8> using the new Encode
trait. Behavior change: a corrupt/invalid header now surfaces as a
MessageError from the codec instead of returning Ok(None) forever
(the old code silently stalled the connection on bad framing).

Mechanically update client.rs, client_inner.rs, connection.rs,
server.rs, and socket_manager.rs to use OwnedMessage (the owned
Message<Vec<u8>> alias) in place of the old non-generic Message type,
and add the explicit alloc/std imports (Vec, Box, String, ToString,
format) that the crate no longer pulls in implicitly now that it is
#![no_std]. Fix socket_manager.rs's connection-reset check to compare
against embedded_io::ErrorKind directly instead of calling a
std::io::Error-shaped .kind() that no longer exists on the new
MessageError::Io(embedded_io::ErrorKind) variant.

Also apply the same generic-type fixes to the echo_server example
(DiagnosticMessage<Vec<u8>>, OwnedMessage) and drop two pre-existing
clippy warnings in simple_client so `cargo clippy --all-targets
--features client,server -- -D warnings` is clean, since both
examples are required to compile for the checkpoint.

No behavioral changes intended beyond the documented codec error
surfacing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Verifies no compile fallout remains in the integration test or examples
after the WP4 no_std port, and adds a Feature flags section to the
README covering the alloc/std/codec/client/server matrix, bare-metal
usage with default-features = false, and cargo test --features
client,server for development.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add examples/bare_metal_codec.rs: builds a RoutingActivationRequest and a
DiagnosticMessage from stack data, encodes each into a fixed-size buffer via
the core-only Encode trait, and exercises the sans-io try_frame() framer
incrementally (partial slice -> Ok(None), then full slice -> decoded
message). Compiles with `--no-default-features` and runs on host for
reporting only.

Add a `feature-matrix` CI job running the full verification matrix from the
migration plan: no_std/alloc/std/client+server checks, the
thumbv7em-none-eabihf cross-check, clippy -D warnings, both test
configurations, and building/running the new example.

Also apply `cargo fmt` to src/lib.rs (pre-existing formatting drift
unrelated to this change) so `cargo fmt --check` passes as part of the
matrix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds backticks to doc items referencing DoIP/DoIPInt/control_sender/
update_receiver/SendDiagnosticMessage; inlines format! args in
Header's Debug impls; uses Duration::from_mins for the 5- and 2-minute
timeouts instead of from_secs; allows needless_pass_by_value on
MessageError::io (used as a bare fn pointer across ~30 map_err call
sites, so taking a reference would force closures everywhere for no
benefit); and allows too_many_lines on the match-heavy
handle_control_message dispatcher rather than force an artificial
split.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
framer::try_frame computed `Header::SIZE + payload_length as usize`
directly, which overflows on 32-bit targets (e.g. thumbv7em) when a
hostile header sets payload_length to u32::MAX, panicking in debug
builds and wrapping in release. Instead compare available payload
bytes (`buf.len() - Header::SIZE < payload_len`) before computing the
total, which is now provably non-overflowing. Added a regression test
with payload_length = 0xFFFFFFFF.

`cargo check --no-default-features --features server` failed to build
(E0432) because socket_manager unconditionally imports
`client::ClientOptions` and `connection`, both gated on the `client`
feature, while socket_manager itself was gated on
`any(client, server)`. socket_manager is only ever used by
client_inner.rs (there is no server usage), so the correct fix is to
gate the `socket_manager` module on `client` alone rather than
sprinkling cfg attributes through its internals. Also added the
missing single-feature `server`/`client`/`codec` cargo check steps to
the CI feature-matrix job so this class of bug is caught going
forward.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Group 4 cleanup, no wire-format changes:

- Add read_optional_array::<N> to decode_util and use it in both
  routing_activation_request and routing_activation_response, replacing
  the copy-pasted `if rest.len() >= 4` blocks.
- Add encode_util with write_u8 / write_u16_be / write_u32_be and convert
  the scalar big-endian / single-byte write_all call sites across
  src/messages/ to use them. Raw slice writes (arrays, user data) stay as
  plain write_all.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Groups 2 and 4c.

Group 2 (bug): routing_activation_response hardcoded the header payload
length to 9, but RoutingActivationResponse::encode writes 13 bytes when
oem_specific is Some. Framing such a message truncated it and broke
round-trip. Build the payload struct first and use
response.encoded_size() as the header length, matching
routing_activation_request. Removes the stale "TODO: Check the payload
length" comment.

  Wire-format change: only the previously-broken oem_specific=Some path
  (header length 9 -> 13). All other paths are byte-identical.

Group 4c: diagnostic_message and diagnostic_message_ack constructors now
build the payload struct first and use payload.encoded_size() instead of
the hand-computed `len + 4` / `5 + len` (same bytes as before).

Adds a regression test round-tripping a routing activation response with
oem_specific set.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Group 1. A peer could crash the decoder or a server connection task with
crafted input; these paths now return errors or handle a real case.

- Payload::decode: replace the todo!() arms. DoIPEntityStatusRequest,
  DoIPEntityStatusResponse and DiagnosticPowerModeInfoResponse are now
  decoded properly (the first is an empty payload, the others delegate to
  their existing Decode impls). DiagnosticPowerModeInfoRequest (no
  payload variant) and the Reserved / ReservedVehicleManufacturer ranges
  return the new MessageError::UnsupportedPayloadType instead of
  panicking.
- Payload::VehicleAnnouncement now wraps VehicleIdentificationResponse
  (its actual 0x0004 wire format) instead of being a unit variant, so
  encoded_size/encode delegate to it (a real implementation) rather than
  todo!(). This fixes the decode -> re-encode panic. Debug and
  to_owned_message updated accordingly.
- server: a codec/decoding error on a client stream now logs and closes
  that connection gracefully instead of panic!().

  Wire-format: no change to working paths. VehicleAnnouncement encode was
  previously unreachable (panicked); it now emits the standard 33-byte
  vehicle identification response.

Adds tests: unsupported payload_type decodes to Err (no panic), entity
status request decodes empty, and VehicleAnnouncement round-trips.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Group 3. Flattening socket errors to embedded_io::ErrorKind lost the OS
error code/message in socket_manager logs.

- Add a std-gated MessageError::Std(std::io::Error) variant that keeps
  the full error, and make From<std::io::Error> produce it. The no_std
  core is untouched and still uses Io(ErrorKind).
- socket_manager: handle the new Std variant equivalently to Io,
  including the ConnectionReset check (matched via io_err.kind()), and
  log the full error so the OS detail is preserved.

No wire-format change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
RoutingActivationResponse and VehicleIdentificationResponse are
server->client messages; a peer sending either is a protocol-role
violation. handle_client_message previously hit todo!() for both,
panicking the connection task on network-reachable input (same class
fixed for decoder/server paths in bdab87c). Now warn! and return
Error::UnexpectedMessageType, matching the existing catch-all arm and
the graceful-close handling already in place.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
EntityStatusRequest (0x4001) and VehicleIdentificationRequest
(0x0001-0x0003) decode successfully since bdab87c, so a peer sending
either reached handle_client_message's todo!() and panicked the
connection task -- same remote-DoS class as the previous fixes. These
are legitimate client->server requests the server just doesn't
implement yet, so warn that they are not yet supported and ignore them
(no response) instead of panicking. handle_client_message now returns
Result<Option<OwnedMessage>, Error>; the connection loop only writes a
response when one is produced and stays open otherwise.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
handle_client_connection incremented active_connections on entry but
only decremented on two of its exit paths. Any `?` early return from
handle_client_message (e.g. UnexpectedMessageType) or from
write_sink.send skipped the decrement, permanently inflating the
counter. A peer could repeatedly trigger these paths to leak all
connection slots.

Replace the manual fetch_add/fetch_sub pairing with an
ActiveConnectionGuard RAII type whose Drop always decrements the
counter, so every exit path (return, `?`, or panic) is covered.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replace the empty integration_test.rs stub with real end-to-end tests that
drive a Server and Client over an actual localhost TCP socket: routing
activation, a diagnostic message round trip, and two hardening regressions
(an unsupported payload type and a malformed header must close the offending
connection without taking the server down).

Servers bind to 127.0.0.1:0 and tests read back the OS-assigned port via
Server::handle_client_connection (already public) instead of the
fixed-port run_server(), and clients use a small test-only Connector
(the documented extension point in connection.rs) since ConnectorSocket
only accepts TCP_PORT. No src/ changes were needed.

Also add net/io-util/time to the dev-dependency tokio features so the
tests can drive raw TcpStream I/O; this is dev-only and does not affect
the published feature set or no_std build.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The test harness's accept loop previously spawned a task per accepted
connection, which would contain a panic escaping handle_client_connection
and let the server-survival tests pass even if a future regression
reintroduced a panic on malformed/unsupported input - the exact class of
regression those tests exist to catch. Server::run_server awaits the
handler inline in its accept loop, so a handler panic kills it in
production.

Restructure the harness to await handle_client_connection inline and
sequentially, mirroring run_server's real control flow (the tests only
ever need one connection at a time), and fix the comments that claimed
spawn-based parity. Also replace the bare accept_loop.abort() at test end
with a shutdown() helper that first asserts the accept loop is still
alive - turning a dead accept loop into an explicit failure - and then
aborts and awaits the task.

Verified by temporarily reinserting a panic on the codec-error path in
handle_client_connection: both unsupported_payload_type_does_not_kill_server
and malformed_header_does_not_kill_server now fail under that simulated
regression, and pass again once it is removed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant