Repository navigation
feat(fuzz): fuzz storage services via nisshi-sans-io's new arbitrary feature - #807
shortishly wants to merge 29 commits into
Conversation
sgamelin
left a comment
There was a problem hiding this comment.
Approving. Deriving Arbitrary in build.rs is a neat way to cover every message type at once, and the fixes turn several client-triggered connection drops into proper error codes.
One thing to fix before merging: fetch.rs:452 sends a frame clients can't decode (details inline). It's a one-line change.
Worth adding here or in a follow-up: regression tests for the four fixes. just test-workspace excludes fuzz and no CI job runs the targets, so reverting any of the fixes today fails no test. nisshi-broker/tests/it/storage_list_offsets.rs and storage_fetch.rs already run against multiple backends and look like the natural home. For example:
- ListOffsets with one valid and one invalid timestamp in the same topic: the valid partition still resolves, the other gets
INVALID_TIMESTAMP, and both sit under one topic entry. - ListOffsets with
isolation_level = 7: every partition getsINVALID_REQUEST. - Fetch with a bad
isolation_levelor a negativemin_bytes: top-levelINVALID_REQUEST, and the response encodes and decodes. - AlterUserScramCredentials with one bad and one good mechanism: the bad one gets
UNSUPPORTED_SASL_MECHANISMand the good one is stored.
A smaller gap for later: Records is nullable, so #[arbitrary(default)] makes it None and fuzz_produce_storage never reaches the batch-handling code in ProduceService. Building batches with the existing encoder, which computes the CRC, would open that path up.
806: Fuzzing support for the protocol layerI've tried to cover most of the feedback above (thanks for al that!) - the main thing that is missing are error codes on earlier version FetchResponses: I'm wanting to try putting "validation" as a separate service/layer for each API rather than mixing it into the actual service. I've also made (possibly) overlapping changes with #823, the fuzzing has surfaced several unbounded allocation issues in parts of the decoding. I'm leaving this PR as draft for the moment. SummaryAdds fuzz testing for What's new
Bugs found and fixed
Verification
|
sgamelin
left a comment
There was a problem hiding this comment.
I looked at the four commits from 5 Oct. The per-partition mirror matches Kafka's FetchRequest.getErrorResponse for pre-v13 clients, which fixes what I raised on the old line 452. The marker refactor is correct as well. KIP-893's presence marker is an int8 in every version, so that branch never did anything.
I left three comments inline. The first matters most, because it covers the layer pattern before it spreads to other APIs.
Also: main now has CHANGELOG.md. The decoder and error-code fixes here change what clients see, so they probably want entries.
solace-aross
left a comment
There was a problem hiding this comment.
Thanks for the iteration on the fetch validation layer. I read the sans-io changes (de.rs, ser.rs, tagged.rs) and the storage fixes; I did not run them locally, so treat the points below as read-only review.
What reads correct:
• is_nullable gated on in_seq_of_primitive: the sequence's nullability no longer leaks onto its elements.
• Tagged-field preallocation capped at 4096: the claimed length no longer drives with_capacity. The loop still stops at the end of the buffer ('byte' error), so allocation is bounded by input size. Removing the old length > 128 check also means tagged data can now be as large as the frame, so this relies on the pre-auth frame limit from #823 staying in place.
• Mandatory sequence at None encodes as an empty array, and the KIP-893 marker is written for a populated nullable struct.
Blocking merge, not the design:
• The branch conflicts with main in CHANGELOG.md and nisshi-storage/src/service/list_offsets.rs. #823 touches the same decode limits, so please re-check the tagged.rs change still makes sense on top of it after the rebase.
• zizmor is red on this head. Main has had a red push run recently as well, so it may not be yours; worth confirming after the rebase.
Sam has the layer-pattern question covered. I will re-review after the rebase.
…feature Add an `arbitrary` cargo feature to nisshi-sans-io: build.rs now derives `Arbitrary` on every generated public message type, using `#[arbitrary(with = ...)]` for `bytes` fields (a foreign type we can't impl Arbitrary for directly) and `#[arbitrary(default)]` for `records` fields (whose crc/batch_length invariants arbitrary generation would violate). Add 23 fuzz targets (fuzz/fuzz_targets/fuzz_*_storage.rs), one per non-transaction broker service, that build a fuzzed request directly via Arbitrary and run it through the real nisshi_storage::*Service against a fresh in-memory backend, failing on panics or any non-Error::Api result. Fix bugs the above found: - list_offsets: invalid isolation_level/timestamp leaked Error::SansIo; now map to per-response/per-partition ErrorCode (InvalidRequest / InvalidTimestamp respectively, scoped to how broadly each field applies) - fetch: invalid isolation_level/max_wait_ms/min_bytes/max_bytes leaked internal errors; now map to FetchResponse's top-level ErrorCode::InvalidRequest - alter_user_scram_credentials: an unrecognized SASL mechanism leaked Error::SansIo; now maps to per-item ErrorCode::UnsupportedSaslMechanism - TopicId::from(&FetchTopic) / TopicId::from(&MetadataRequestTopic) panicked when given neither a name nor a topic id; now falls back to the NULL_TOPIC_ID sentinel already used elsewhere for "no such topic" Known gap left unfixed: delete_records is an unconditional todo!() in nisshi-storage-dynostore (a missing implementation, not an error-mapping bug), so its fuzz target crashes on the first execution by design. Signed-off-by: Peter Morgan <peter.james.morgan@gmail.com>
Signed-off-by: Peter Morgan <peter.james.morgan@gmail.com>
…bitrary Shares check_request/check_response helpers across all storage fuzz targets via fuzz/src/lib.rs, adds a fuzz-all recipe to run every target in turn, and fixes the bugs the fuzzers surfaced: CreateAcls now returns a per-creation SecurityDisabled result instead of an empty response, Fetch returns an empty responses list rather than None on early InvalidRequest, tuple-struct decoding of tagged fields no longer errors out, and an over-eager tagged field length check that rejected valid input is removed. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Signed-off-by: Peter Morgan <peter.james.morgan@gmail.com>
fuzz_describe_configs_storage found that decoding a non-empty, non-null ConfigurationKeys ([]string, nullableVersions "0+") panicked with StringWithoutLength. Decoder::is_nullable() reads self.meta.field, which describes the array field as a whole and is left in place while iterating its elements, so each string element was mistaken for being individually nullable; read_mandatory_non_nullable_length then skipped reading its length prefix. Gate is_nullable() on in_seq_of_primitive, which is already tracked for exactly this scope, and add a round-trip regression test at both a flexible (v4) and classic (v0) API version. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Signed-off-by: Peter Morgan <peter.james.morgan@gmail.com>
A prior commit on this branch (576ecf0) removed a check on TagField's decoded data length because it rejected valid tagged fields larger than 128 bytes. But that check was the only thing standing between an attacker-controlled length varint and Vec::with_capacity(length): fuzz_request_decode found a ~3.5GB claim aborting the process via OOM, pre-authentication, before a single data byte was read. Cap the up-front allocation instead of the field's actual size: legitimate payloads above the cap still decode correctly via incremental reallocation as bytes are pushed, while a malicious length no longer drives an immediate multi-gigabyte allocation. Add a gap6 regression test using the fuzzer's minimized crash input. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Signed-off-by: Peter Morgan <peter.james.morgan@gmail.com>
…nothing Every sequence field is generated as Option<Vec<T>> regardless of whether it's actually nullable on the wire (build.rs's kind()), so a mandatory array can be None at the Rust level without meaning "null" - e.g. a handler's Default::default() response that never populated it. Encoder::serialize_none only handled the genuinely-nullable case and wrote nothing at all for this one, desyncing every field written after it; fuzz_describe_user_scram_credentials_storage caught it as an UnexpectedEof on decode (DescribeUserScramCredentialsService's stub ::default() response left its mandatory `results` field at None). Add a branch that writes a zero-length array marker instead, matching what Some(vec![]) already produces. Add round-trip regression tests at both a flexible and a classic (non-flexible) API version. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Signed-off-by: Peter Morgan <peter.james.morgan@gmail.com>
check_request/check_response asserted the first decode matched the pre-encode value, which produces false positives whenever a value isn't already in its wire-canonical shape: a service populating fields across several API-version eras at once (FindCoordinatorResponse's v0-3 scalars alongside its v4+ coordinators, MetadataResponse's cluster_authorized_operations), or a mandatory Option<Vec<T>> field left at None (wire-equivalent to Some(vec![]) but not PartialEq, per the describe_user_scram_credentials fix). Both are legitimate and not bugs, but the single-round-trip assertion flagged them anyway. Compare a second round-trip against the first instead: whatever the decoder normalized for this api_version is still required to be a fixed point under further encode/decode, which is the actual property a sans-io codec has to satisfy, and it no longer depends on the original value already being shaped exactly as the wire would produce it. Verified against all three previously-false-positive fuzz targets (describe_user_scram_credentials, find_coordinator, metadata): their saved crash artifacts and ~30s of fresh fuzzing each are now clean, while describe_topic_partitions_storage still correctly reports its still-open Cursor encoder bug unchanged. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Signed-off-by: Peter Morgan <peter.james.morgan@gmail.com>
Encoder::serialize_some fell through to a plain value.serialize(self) for a nullable, non-sequence struct field, writing none of the presence marker that serialize_none already writes for the same field shape when the value is null. Per KIP-893, a non-null nullable struct must be preceded by the same marker (INT8 1 non-flexible, VARINT 1 flexible) as a null one (-1 / 0) - without it, every byte written after the struct's own fields is shifted by one. fuzz_describe_topic_partitions_storage caught this via DescribeTopicPartitionsRequest.Cursor; ConsumerGroupHeartbeatResponse and ShareGroupHeartbeatResponse's Assignment fields share the same shape. Write the marker before delegating to the struct's own serialization, and add a round-trip regression test with a populated Cursor. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Signed-off-by: Peter Morgan <peter.james.morgan@gmail.com>
…tition FetchResponse.error_code is versions 7+ and ignorable, so pre-v7 clients never see it on the wire. Populating only the top-level field on a malformed request (bad isolation_level/max_wait_ms/min_bytes/max_bytes) left those clients with an empty responses list and no error signal at all. Mirror ErrorCode::InvalidRequest onto every requested topic/partition too, the same way unknown-topic responses already do. Replayed fuzz_fetch_storage's saved crash corpus and ran a fresh 60s session against the fix; no crashes. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Signed-off-by: Peter Morgan <peter.james.morgan@gmail.com>
…te count Add a Fuzz Testing section covering the justfile fuzz tasks, the three target shapes in fuzz/fuzz_targets/, the check_request/check_response round-trip helpers, and the known fuzz_delete_records_storage exception. Note nisshi-sans-io's arbitrary feature in the sans-io codegen section, and correct the workspace member count (15 -> 22, including fuzz). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Signed-off-by: Peter Morgan <peter.james.morgan@gmail.com>
FetchValidationLayer wraps FetchService in the broker's fetch route and short-circuits to the same INVALID_REQUEST response FetchService already builds for a malformed isolation_level/max_wait_ms/min_bytes/max_bytes, without calling into storage. FetchService and the layer now share one parse_fetch_fields helper instead of validating those four fields twice, and fuzz_fetch_storage fuzzes through the layer too, matching the broker's own routing. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XhQF9azsXxaQCxnY8KwETP Signed-off-by: Peter Morgan <peter.james.morgan@gmail.com>
…presence marker unsigned_varint(1) and serialize_i8(1) both write a single 0x01 byte, so branching on is_flexible() in serialize_some's nullable-struct arm decided nothing. The matching null-case marker above it, and the decoder reading this marker, both already write/read it unconditionally, so the branch only risked implying a flexible/non-flexible difference that doesn't exist here. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XhQF9azsXxaQCxnY8KwETP Signed-off-by: Peter Morgan <peter.james.morgan@gmail.com>
ValidatedFetchRequest implements TryFrom<FetchRequest>, parsing the isolation level, max wait, min and max bytes once. FetchService now takes it, so it no longer parses those fields itself, and FetchValidationService converts the request and answers a malformed one with INVALID_REQUEST. The validation code moves to service/fetch/validation.rs, and a malformed request's error response now reports each topic's own topic id. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Signed-off-by: Peter Morgan <peter.james.morgan@gmail.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Signed-off-by: Peter Morgan <peter.james.morgan@gmail.com>
…ion tests - Log invalid Fetch, ListOffsets and AlterUserScramCredentials fields at warn! with the field name and raw value instead of debug!. - fuzz_fetch_storage: cap max_wait_ms with min(5) so negative values still reach the validation path. - Note that delete_records is also todo!() in the libSQL backend. - Add regression tests for Fetch validation (each bad field, response encodes/decodes), ListOffsets (scoped INVALID_TIMESTAMP, INVALID_REQUEST isolation level) and AlterUserScramCredentials (bad and good mechanism). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Signed-off-by: Peter Morgan <peter.james.morgan@gmail.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Signed-off-by: Peter Morgan <peter.james.morgan@gmail.com>
CreateTopicsService validates both and caps a request at 10,000 partitions before storage is consulted, so the clamp and its comment were stale. Unclamped, the target finds an out-of-memory crash: a topic with one manual assignment counts as one partition against the cap, but dynostore then creates num_partitions watermarks (here 9,667,468). The target crashes until CreateTopicsService rejects num_partitions with assignments. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Signed-off-by: Peter Morgan <peter.james.morgan@gmail.com>
…de assignments CreateTopicsService now takes a ValidatedCreateTopicsRequest built by CreateTopicsValidationLayer, following the Fetch pattern. The layer answers a request over the 10,000 partition cap with POLICY_VIOLATION, and each invalid topic on its own. A topic with manual assignments and an explicit num_partitions or replication_factor is now INVALID_REQUEST, as in Kafka. The cap counted only the assignments while storage created num_partitions partitions, so one request could exhaust the broker's memory (found by fuzz_create_topics_storage at 9,667,468 partitions). The fuzz target no longer clamps either field. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Signed-off-by: Peter Morgan <peter.james.morgan@gmail.com>
ProduceService returns AcksZeroProduceFailed when an acks=0 request has a failing partition, so that the broker closes the connection as Kafka does. The target treated that deliberate error as a defect and panicked. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Signed-off-by: Peter Morgan <peter.james.morgan@gmail.com>
A fuzzed request is an arbitrary in-memory value, but a broker only serves what came off the wire. normalize_request encodes a request and decodes it again, and each fuzz_<api>_storage target now serves that decoded request, so the inputs keep to shapes a client can send. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Signed-off-by: Peter Morgan <peter.james.morgan@gmail.com>
4e01d22 to
13964ef
Compare
The doc comment on BROKER_DEFAULT_NUM_PARTITIONS linked to CreateTopicsService::serve, which does not resolve from this module and broke `just doc`. The -1 substitution happens in ValidatedCreateTopicsRequest's TryFrom impl, so link there. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Signed-off-by: Peter Morgan <peter.james.morgan@gmail.com>
…or-the-protocol-layer Signed-off-by: Peter Morgan <peter.james.morgan@gmail.com> # Conflicts: # justfile
sgamelin
left a comment
There was a problem hiding this comment.
Approving. Every thread from the earlier rounds is addressed, and ValidatedFetchRequest is the shape I was hoping for. I ran the sans-io and storage suites and the broker tests for the changed APIs locally, and they all pass.
None of the inline comments block the merge. Two things for the description, since it becomes the squash commit body:
- It doesn't mention the stricter CreateTopics check (manual assignments plus explicit counts) or the CreateAcls change.
delete_recordsis atodo!()in the libSQL (lite.rs#L2517) and turso (limbo.rs#L1236) backends too, not only in dynostore.
#939 adds a CI workflow for the fuzz targets, and the two PRs overlap:
- Its
fuzz/ci-targets.jsonlists only the five decode targets, so once this lands,fuzz-ciwarns about the 23 new*_storagetargets.fuzz_delete_records_storageneeds to stay out of every tier untildelete_recordsis implemented, because it panics on its first input. cargo fuzz buildwith no target builds every bin. #939 measured about 33 minutes for five targets against a 50 minute PR job timeout, so the storage targets need a tier decision rather than all going intopr.- This PR's CLAUDE.md says the fuzzers are "not run in CI", and
fuzz-alloverlaps with #939'sfuzz-ci. Whichever PR merges second will need to reconcile both.
…or-the-protocol-layer Signed-off-by: Peter Morgan <peter.james.morgan@gmail.com>
Carry the request's Extensions through the Fetch and CreateTopics validation layers, use Kafka's messages for rejected CreateTopics assignments, SCRAM mechanisms and CreateAcls, log one warning per ListOffsets request, and test the tagged field preallocation cap. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Signed-off-by: Peter Morgan <peter.james.morgan@gmail.com>
sgamelin
left a comment
There was a problem hiding this comment.
Approving again. 6586c75 addresses everything from my last review:
- The Fetch and CreateTopics validation layers carry
Extensionsthrough, so the pattern is safe to copy to Produce. - CreateTopics, AlterUserScramCredentials and CreateAcls send Kafka's messages. CreateTopics checks the replication factor first and logs the rejection.
- ListOffsets logs one
warn!per request and says whyINVALID_REQUESTdiffers from Kafka. - New tests cover the SCRAM deletion branch, CreateAcls with 0, 1 and 3 creations, and the tagged-field preallocation cap.
- The description now covers the CreateTopics and CreateAcls changes and the libSQL and turso
delete_recordsstubs.
Two smaller points are still open, and neither blocks: the harness suggestions (fuzzing across versions, comparing encodes byte for byte), and a note that rejecting a negative max_wait_ms (validation.rs#L106) is stricter than Kafka. Both are fine as follow-ups.
Before merging: #939 is on main now, and the branch conflicts with it in justfile. Resolving that is the reconciliation from my last review: give the 23 *_storage targets a tier in fuzz/ci-targets.json (or leave them out on purpose), keep fuzz_delete_records_storage out of every tier, and update the "not run in CI" line in CLAUDE.md#L150.
…or-the-protocol-layer Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Signed-off-by: Peter Morgan <peter.james.morgan@gmail.com>
…tricter max_wait_ms check Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Signed-off-by: Peter Morgan <peter.james.morgan@gmail.com>
solace-aross
left a comment
There was a problem hiding this comment.
Approving at 1097ab1. Sam approved the substance at 6586c75; the one commit since moves the storage targets to the nightly tier and touches docs and a comment.
Checked at this head:
- Ran the sans-io and storage suites for the changed code: 177/177 (encode/decode, tagged fields, both validation layers, list_offsets, create_acls, scram). Memory backend only. Removing the mandatory-array-at-None branch in ser.rs makes both mandatory_sequence_encoding tests fail.
- fuzz-pr shows red on this head, but that run was cancelled by the concurrency cap. The other checks pass.
- git merge-tree against main is clean.
Two notes, neither blocking:
- validate_topic now requires -1 counts beside a manual assignment and then substitutes the broker default of 3 partitions, and storage ignores the assignments. A client sending NewTopic(name, assignments) for six partitions gets three. This predates the PR, but the layer is now the place to set num_partitions from the assignments.
- The nightly fuzz matrix grows from 5 to 28 legs, each with its own cargo fuzz build. Worth a look at the first few runs.
Sam's earlier thread on the tagged-field regression test (it still passes with the cap removed) is worth a follow-up.
…or-the-protocol-layer Signed-off-by: Peter Morgan <peter.james.morgan@gmail.com>
solace-aross
left a comment
There was a problem hiding this comment.
Approving at c9d7411. This is a merge of main (#908) into the branch; the only conflict was fuzz/ci-targets.json, and the resolution keeps both sides: fuzz_batch_records from main plus the 27 storage targets in the nightly tier (28 entries, pr tier unchanged at 6). Nothing else differs from 1097ab1 apart from what #908 brought in. All 17 threads are resolved.
…or-the-protocol-layer Signed-off-by: Peter Morgan <peter.james.morgan@gmail.com>
…er tests The tests merged from main call CreateTopicsService with a plain CreateTopicsRequest, but on this branch it takes a ValidatedCreateTopicsRequest. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Signed-off-by: Peter Morgan <peter.james.morgan@gmail.com>
solace-aross
left a comment
There was a problem hiding this comment.
Approving at 0d18187. The delta since c9d7411 is a second merge of main and one test-only fix: the three broker tests from #805 now wrap CreateTopicsService in CreateTopicsValidationLayer, matching the ValidatedCreateTopicsRequest it takes on this branch. Production code is unchanged by that commit. All 17 threads are resolved.
Add an
arbitrarycargo feature to nisshi-sans-io: build.rs now derivesArbitraryon every generated public message type, using#[arbitrary(with = ...)]forbytesfields (a foreign type we can't impl Arbitrary for directly) and#[arbitrary(default)]forrecordsfields (whose crc/batch_length invariants arbitrary generation would violate).Add 23 fuzz targets (fuzz/fuzz_targets/fuzz_*_storage.rs), one per non-transaction broker service, that build a fuzzed request directly via Arbitrary and run it through the real nisshi_storage::*Service against a fresh in-memory backend, failing on panics or any non-Error::Api result. The targets run in the nightly tier of fuzz/ci-targets.json (15 minutes each; see fuzz/README.md), and locally with
just fuzz-ci nightly <target>.Fix bugs the above found:
ValidatedFetchRequest(TryFrom<FetchRequest>), whichFetchServicetakes instead of a rawFetchRequest.FetchValidationServiceconverts the request and maps a malformed one to the top-levelErrorCode::InvalidRequest, mirrored onto every partition for pre-v7 clientsKnown gap left unfixed: delete_records is an unconditional todo!() in nisshi-storage-dynostore and in the libSQL (nisshi-storage-sql/src/lite.rs) and turso (limbo.rs) backends, so a DeleteRecords request panics on memory://, s3:// and sqlite:// brokers, and its fuzz target crashes on the first execution. It is in no tier of fuzz/ci-targets.json, so CI never runs it.
Operator-visible changes: Fetch, ListOffsets and AlterUserScramCredentials answer invalid fields with an error code instead of closing the connection; CreateTopics rejects manual assignments with explicit counts; CreateAcls answers SECURITY_DISABLED per creation.
What does this change do, and why?
Makes the protocol layer fuzzable and fixes the error-mapping and panic bugs the fuzz targets found (see above).
How was this tested?
just cithenjust test: 1109 nextest tests and all doc tests pass.Checklist
git commit -s) — see CONTRIBUTING.mdjust fmt,just clippy, andjust testpass locally