Add pre-publish code review #002 (2 medium, 3 low, 2 nit)

Full source read of all 13 src/*.rs files for correctness, panic
safety, and API ergonomics ahead of v0.1.0 crates.io publish.

Findings:
- M1: Definitions::merge_into silently drops data via double-
  unwrap_or_default chain; untested public API
- M2: write_bytes truncates u32 length prefix on >4GiB data
  (data_len as u32 without bounds check)
- L1: materialize.rs union path uses unchecked offset arithmetic
  (sequential_reader.rs sibling uses checked_add)
- L2: three unreachable!() in production code (offset_map, layout_
  builder, materialize)
- L3: AlkTypeError::source() returns None for Validation variant
  (ValidationError implements std::error::Error)
- N1: is_rfc3339_timestamp is non-strict (Feb 31 passes, seconds
  unchecked)
- N2: SequentialReader returns FieldValue::Bytes for Record (API
  asymmetry vs other composites)

Verification baseline (commit c0217d9):
- cargo test --release: 438 tests pass (391 crate + 47 integration)
- cargo clippy --all-targets -- -D warnings: clean
- cargo build --target wasm32-unknown-unknown --release: clean
- no unsafe, no TODO/FIXME, all unwrap/expect/panic in test modules
This commit is contained in:
2026-08-11 08:38:39 +00:00
parent c0217d91a8
commit ee6e773123

View File

@@ -0,0 +1,468 @@
---
status: open
last_updated: 2026-08-11
reviewed_artifacts:
- src/lib.rs
- src/builder.rs
- src/data_access.rs
- src/engine.rs
- src/error.rs
- src/layout_builder.rs
- src/materialize.rs
- src/offset_map.rs
- src/schema.rs
- src/sequential_reader.rs
- src/tunion.rs
- src/validation.rs
- src/macros.rs
- tests/{engine_integration,error_paths,poc_roundtrip,tunion_dispatch}.rs
- Cargo.toml
tool: manual source read + cargo test/clippy/wasm build
reviewer: pre-publish code review (pre-cargo-publish sweep)
---
# Code Review #002 — Pre-Publish Sweep
## Purpose
First logic/correctness review of the alktype crate ahead of the
v0.1.0 crates.io publish. This pass complements the coverage review
(#001) by reading every source file for correctness, code smell, panic
safety, and API ergonomics — the things a downstream consumer
(notably the upcoming `alkcall` crate, the alknet-call/alknet-channels
unification) will trip over.
This review deliberately defers documentation polish (README, inline
doc cleanup for docs.rs) and coverage gaps to subsequent sweeps, per
the publisher's stated workflow. The scope here is: no panics in
production paths, no silent data corruption, no API that returns
nonsense, and nothing that would embarrass the crate on docs.rs on day
one.
## Methodology
- Full read of all 13 `src/*.rs` files (production + test modules).
- Grep for `unwrap`/`expect`/`panic!`/`unreachable!`/`unsafe`/`TODO`/
`FIXME` to classify every hit as production or test-only.
- Grep for `as u32`/`as usize`/`as i32`/`as i64`/`as u64` to find
truncation-prone casts.
- `cargo build --release`, `cargo test --release`,
`cargo clippy --all-targets -- -D warnings`,
`cargo build --target wasm32-unknown-unknown --release`.
- Cross-reference every error path against its caller to confirm errors
propagate (not swallowed) and carry useful attribution.
- Read `docs/reviews/001-coverage-analysis.md` for prior context and
resolved/unresolved items.
## Summary Statistics
| Severity | Count |
|------------|------:|
| Critical | 0 |
| Medium | 2 (M1, M2) |
| Low | 3 (L1, L2, L3) |
| Nit | 2 (N1, N2) |
No critical findings. The crate is in good shape for a 0.1.0 publish
once M1 and M2 are resolved. The Medium findings are both small,
localized, and have clear fixes; the Low/Nit findings are deferrable.
## Verification Baseline
All verification run on the reviewed tree (commit `c0217d9`):
- `cargo test --release`: **438 tests pass** (391 crate unit tests +
47 integration tests across 4 files). Zero failures.
- `cargo clippy --all-targets -- -D warnings`: **clean**.
- `cargo build --release`: clean.
- `cargo build --target wasm32-unknown-unknown --release`: **clean**.
The wasm32 target is a first-class supported target (the alkcall
consumer will rely on this — confirmed working).
- No `unsafe` anywhere in the crate.
- No `TODO`/`FIXME`/`HACK`/`XXX` markers in source.
- All `unwrap`/`expect`/`panic!`/`unreachable!` are confined to
`#[cfg(test)]` modules, verified by line-context cross-reference.
Production code uses `?`, `ok_or_else`, `checked_add`, and
`Result`-returning helpers throughout.
---
## Findings
### M1. `Definitions::merge_into` is convoluted and silently drops data
**File**: `src/builder.rs:493-503`
**Problem**: `Definitions::merge_into` is the public convenience API
for merging `$defs` into a top-level schema. The implementation goes
through `self.build()` (which returns `{"$defs": {...}}`), then
extracts the inner `$defs` object via a double-`cloned`/double-
`unwrap_or_default` chain:
```rust
pub fn merge_into(self, top: &mut Value) {
if let Some(obj) = top.as_object_mut() {
if let Some(defs) = self.build().as_object() {
if let Some(existing) = obj.get_mut("$defs").and_then(Value::as_object_mut) {
existing.extend(defs.get("$defs").cloned().unwrap_or_default()
.as_object().cloned().unwrap_or_default());
} else {
obj.insert("$defs".to_string(), Value::Object(
defs.get("$defs").cloned().unwrap_or_default()
.as_object().cloned().unwrap_or_default()));
}
}
}
}
```
Two issues:
1. **Silent data loss**: if `self.build()` ever produces a shape other
than `{"$defs": Object(_)}` (a future bug, or a refactor that adds
another key), the `.unwrap_or_default()` chain silently substitutes
an empty map — the definitions vanish without an error. This is the
worst failure mode for a builder API: the consumer thinks they
merged definitions, the schema compiles, and `$ref` resolution
silently fails downstream.
2. **Readability**: the double-`cloned().unwrap_or_default().as_object()
.cloned().unwrap_or_default()` chain is hard to read and hard to
audit. A reviewer cannot tell at a glance whether it's correct.
**Also**: `merge_into` has **no test coverage**. The
`builder_definitions_define_returns_ref` test covers
`Definitions::build()` but not `merge_into`. The OQ-006 resolution
work referenced `merge_into` in `builder.md` Example 3, so the API is
intended to be used — but it's never exercised.
**Fix**: Simplify by accessing the `defs` field directly (the method
already takes `self` by value) instead of round-tripping through
`build()`. Then add tests covering both the "merge into schema with
existing `$defs`" and "merge into schema without `$defs`" branches.
```rust
pub fn merge_into(self, top: &mut Value) {
if let Some(obj) = top.as_object_mut() {
match obj.get_mut("$defs").and_then(Value::as_object_mut) {
Some(existing) => existing.extend(self.defs),
None => {
obj.insert("$defs".to_string(), Value::Object(self.defs));
}
}
}
}
```
This requires moving the method outside the `impl Definitions` block
that consumes `self.defs` via `build()`, or restructuring so `merge_into`
takes ownership of `self.defs` directly. Either way, the
`unwrap_or_default` chain disappears.
**Lift**: removes a silent-data-loss bug class, makes the public API
auditable, adds test coverage to an untested public method. Small
effort (~15 lines + ~15 lines of tests).
---
### M2. `write_bytes` silently truncates length prefix on >4 GiB data
**File**: `src/data_access.rs:264-278`
**Problem**: `write_bytes` casts `data_len: usize` to `u32` for the
length prefix without checking bounds:
```rust
let data_len = value.len();
let total = U32_SIZE.checked_add(data_len).ok_or_else(/* overflow */)?;
let end = offset.checked_add(total).ok_or_else(/* overflow */)?;
check_bounds(buffer.len(), offset, end, field_path)?;
write_array(buffer, offset, u32_to(data_len as u32, endian), field_path)?;
```
If `value.len() > u32::MAX` (on a 64-bit platform with a >4 GiB blob),
`data_len as u32` truncates. The `check_bounds` call above passes
(the buffer genuinely is that large, or `total` overflowed `usize`
first on a 32-bit target), but the written length prefix doesn't
match the actual data length. A subsequent `read_bytes` returns a
short slice — silent data corruption.
The `checked_add` guards on `total` and `end` catch `usize` overflow
but not the `u32` truncation, which can happen before `usize`
overflow on 64-bit platforms (where `usize` is 64-bit and `u32::MAX
< usize::MAX`).
The same `as u32` cast pattern appears in:
- `src/tunion.rs:457,493` — test-only, fine.
- `src/sequential_reader.rs:860` — test-only helper, fine.
- `src/engine.rs:857` — test-only, fine.
Only `data_access::write_bytes` is the public API path.
**Fix**: validate the cast before writing:
```rust
let data_len_u32 = u32::try_from(data_len).map_err(|_| {
access_err(
field_path,
format!("data length {data_len} exceeds u32::MAX (length prefix width)"),
)
})?;
write_array(buffer, offset, u32_to(data_len_u32, endian), field_path)?;
```
**Lift**: closes a silent-corruption path on large inputs. ~3 lines.
The 64-bit >4 GiB case is rare but not impossible (the crate handles
binary blobs via `AlkType:Bytes`; a safetensors-style data format
could plausibly have large blobs). For a 0.1.0 publish, closing this
path is worth the 3 lines.
---
### L1. `materialize.rs` union path uses unchecked offset arithmetic
**File**: `src/materialize.rs:316,319,336`
**Problem**: The byte-offset discriminator path in
`materialize_union_packed` uses plain `+` for `offset + disc_offset`:
```rust
let disc_value = match disc_type {
AlkTypeKind::Uint8 => {
data_access::read_u8(buffer, offset + disc_offset, field_path)? as u32
}
AlkTypeKind::Uint16 => {
data_access::read_u16(buffer, offset + disc_offset, field_path, endian)? as u32
}
...
};
let variant_offset = offset + disc_offset + disc_type.type_size().unwrap_or(1);
```
`offset + disc_offset` can overflow `usize`. The sibling implementation
in `sequential_reader.rs::read_union_value` (lines 416-424) correctly
uses `checked_add`. The materializer should match.
Low severity because:
- `offset` comes from the materializer's own cursor (always small in
practice).
- `disc_offset` comes from the schema (`discriminator.offset`), which
is attacker-controllable in a "load untrusted schema" scenario — but
the crate doesn't currently document whether loading untrusted
schemas is a supported use case.
Consistency with `sequential_reader.rs` is the main argument for
fixing this now.
**Fix**: replace `offset + disc_offset` with
`offset.checked_add(disc_offset).ok_or_else(|| ...)?` in both spots.
~6 lines, mirrors the existing `sequential_reader.rs` pattern.
---
### L2. Three `unreachable!()` in production code
**Files**: `src/offset_map.rs:277`, `src/layout_builder.rs:285`,
`src/materialize.rs:324`
**Problem**: Three match arms use `unreachable!()` to assert
exhaustiveness over `AlkTypeKind`:
- `offset_map.rs:277`: `_ => unreachable!("all AlkTypeKind variants are covered above")`
- `layout_builder.rs:285`: same
- `materialize.rs:324`: `_ => unreachable!("disc_type restricted by parse_discriminator")`
These are all genuinely unreachable given the match arms above them —
the `AlkTypeKind` enum is fully covered in each case, and
`parse_discriminator` restricts `disc_type` to `Uint8/Uint16/Uint32`
before this point. This is the idiomatic Rust pattern for
exhaustiveness and the compiler will warn if a new variant is added
without updating the match.
**Not a bug**. But for a library that may eventually load schemas from
untrusted input, an `unreachable!` is a panic in production. The
defense-in-depth alternative is to return an `AlkTypeError::Schema`
instead, so a future enum extension (or a logic bug in
`parse_discriminator`) produces an error rather than a panic.
**Fix** (optional): replace each `unreachable!(msg)` with
`Err(AlkTypeError::Schema(format!("internal: {msg}")))`. The compiler
still warns on non-exhaustive matches (the `_` arm catches nothing
once all variants are listed), so this doesn't lose the exhaustiveness
check. ~3 lines per site. Defer if the "untrusted schema" use case
isn't on the v0.1.0 roadmap.
---
### L3. `AlkTypeError::source()` returns `None` for `Validation` variant
**File**: `src/error.rs:45`
**Problem**: `AlkTypeError` implements the blanket
`impl std::error::Error for AlkTypeError {}`, so `source()` always
returns `None`. The `Validation` variant wraps a
`jsonschema::ValidationError<'static>`, which itself implements
`std::error::Error` and may carry a cause chain.
Downstream consumers (the alkcall logging/diagnostics layer) may want
to walk the cause chain for structured error reporting. Currently
they can only `Display` the `ValidationError` (flattened into the
`AlkTypeError::Display` string), not traverse it.
Verified: `jsonschema::ValidationError` implements
`std::error::Error` (in `jsonschema-0.46/src/error.rs`), so the
`source()` override is sound.
**Fix**:
```rust
impl std::error::Error for AlkTypeError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
AlkTypeError::Validation(e) => Some(e),
_ => None,
}
}
}
```
Note the lifetime: `AlkTypeError::Validation` holds a
`ValidationError<'static>`, so the `+ 'static` bound in `source()` is
satisfied. The existing `source_returns_none_for_all_variants` test
(in `error.rs` tests) asserts `source().is_none()` for `Schema`/
`Offset`/`Access` — those still pass. The `Validation` arm needs a new
assertion (`source().is_some()`).
**Lift**: small ergonomics win for downstream consumers; ~6 lines +
~5 lines of test. Defer if no consumer needs cause-chain walking
yet, but cheap to do now.
---
### N1. `is_rfc3339_timestamp` is a non-strict hand-rolled validator
**File**: `src/validation.rs:351-385`
**Problem**: The `is_rfc3339_timestamp` function is a hand-rolled
datetime validator. It checks year > 0, month 1..=12, day 1..=31,
hour 0..=23, minute 0..=59 — but:
- Day-of-month per month is not validated (Feb 31, Apr 31 pass).
- Seconds are not checked against 0..=59 (the code checks minutes but
not seconds — line 384 only validates `time_parts[1]`, not
`time_parts[2]` if present).
- The `rfind('-')` timezone-offset heuristic at line 363 uses
`pos >= 8` to distinguish a date-separator `-` from a timezone
offset `-`. This works for `2026-07-20T10:30:00-05:00` but is
fragile for unusual-but-valid inputs.
The function is documented as "Simple RFC 3339 / ISO 8601 datetime
check", so the non-strictness is acknowledged. For 0.1.0 this is
acceptable — `AlkType:Timestamp` is a length-prefixed string at the
binary level, and strict RFC 3339 validation is the consumer's
responsibility if they need it.
The canonical fix would be to use the `chrono` or `time` crate's
parsing, but that adds a dependency. Not worth it for 0.1.0.
**Fix** (optional): add a doc comment noting the non-strictness
explicitly, so consumers on docs.rs know not to rely on it for strict
validation. ~2 lines of doc.
---
### N2. `SequentialReader` returns `FieldValue::Bytes` for `AlkType:Record`
**File**: `src/sequential_reader.rs:716`
**Problem**: `read_record_value` returns
`FieldValue::Bytes(&buffer[offset..position])` — a raw byte slice —
for a `Record` field. The other composite kinds return typed
descriptors (`Struct { start, end }`, `Union { discriminator,
variant_start }`, `Array { count, element_start, element_stride }`).
The doc comment on `read_record_value` says "the consumer recurses
into the record's value schema", so the behavior is documented. But
returning `FieldValue::Bytes` rather than a `FieldValue::Record { start,
end, count }` is a minor API ergonomics smell: the consumer has to
know that a `Record` field comes back as `Bytes`, while every other
composite comes back as a typed variant.
Not a bug. Not worth fixing for 0.1.0 (would require adding a
`FieldValue::Record` variant and updating consumers). Flagged for
awareness — if the alkcall consumer finds the `Record` API awkward,
revisit in a follow-up.
**Fix**: none for 0.1.0. Document as a known API asymmetry.
---
## What's Good
The crate is in notably good shape for a 0.1.0. Highlights:
- **Overflow safety is thorough**: `checked_add` everywhere in hot
paths (`data_access`, `sequential_reader`, `layout_builder`,
`materialize`). This is rare and good — most crates use `+` and
panic on overflow. M2 is the one place this discipline slipped
(the `as u32` cast).
- **Zero-copy reads**: `read_string`/`read_bytes` return borrowed
slices — no allocation in the read path. Critical for the protocol-
parsing use case.
- **Error attribution**: every `Access`/`Offset` error carries a
`field_path` string. Excellent for debugging wire-format issues.
- **Bounds checks before slicing**: `check_bounds` then `get(..)`
with `ok_or_else` — defensive, no panics on bad offsets.
- **`build_validator` registers all 19 keywords** — no silent
passthrough where an `AlkType:*` kind is accepted but not validated.
- **Tests are excellent**: 438 tests, good coverage of error paths
and edge cases (short buffers, unknown discriminators, invalid
UTF-8, both endiannesses, overflow guards where reachable). The
test discipline is high.
- **wasm32 works** — confirmed clean build. The alkcall consumer's
napi/wasm adapter path is open.
- **No `unsafe`, no `TODO`/`FIXME`** — clean codebase hygiene.
- **Error type is well-designed**: four variants covering the three
engine phases + validation, with `Display` carrying the field path.
L3 is a small ergonomics gap, not a design flaw.
---
## Recommended Order
1. **M2** (u32 truncation in `write_bytes`) — 3 lines, closes a
silent-corruption path. Do this first; it's the smallest and
highest-value fix.
2. **M1** (`Definitions::merge_into`) — ~15 lines + ~15 lines of
tests, removes a silent-data-loss path and adds coverage to an
untested public API. Do this before publish since `merge_into` is
referenced in `builder.md` Example 3.
3. **L1** (materializer unchecked arithmetic) — ~6 lines, mirrors
the existing `sequential_reader.rs` pattern. Cheap to do
alongside M2.
4. **L3** (`AlkTypeError::source`) — ~6 lines + ~5 lines of tests.
Small ergonomics win; cheap to do now.
5. **L2** (`unreachable!` → `Err`) — ~9 lines across three sites.
Optional; defer if the "untrusted schema" use case isn't on the
v0.1.0 roadmap.
6. **N1, N2** — documentation only; defer to the docs sweep.
After M1, M2, L1, and L3, the crate is ready for the pre-publish
sanity check (README, inline docs, docs.rs render) and publish.
---
## Notes
- All line numbers refer to the tree at commit `c0217d9` (the last
commit on `main` at review time).
- The wasm32-unknown-unknown target was verified as a clean build.
The crate's only dependencies (`jsonschema` with
`default-features = false`, `serde_json` with `preserve_order`)
are wasm-compatible — no `std::time`, no filesystem, no threads.
- This review does not cover documentation quality (README, inline
docs, docs.rs rendering). Per the publisher's workflow, that's a
separate sweep after the code is settled.
- Coverage gaps from review #001 (S4 sequential reader error paths,
S8 overflow guards) are not re-litigated here. They remain
coverage gaps, not correctness issues.