Fix M1, M2, L1, L3 from code review #002

Four of seven review findings resolved. 5 new tests (391 -> 396 crate
tests; 438 -> 443 total). cargo test, clippy, wasm32 all green.

M2 (data_access.rs): write_bytes now validates data_len fits in u32
before the length-prefix cast. A >4GiB blob returns Access error
instead of silently writing a truncated length prefix (silent data
corruption on read-back).

M1 (builder.rs): Definitions::merge_into rewritten to access self.defs
directly instead of round-tripping through self.build() with a double-
cloned/unwrap_or_default chain that could silently drop definitions
on a shape mismatch. 4 new tests: insert-when-absent, merge-into-
existing, overwrite-duplicate-keys, no-op-on-non-object-top.

L1 (materialize.rs): byte-offset discriminator arm of
materialize_union_packed now uses checked_add for offset+disc_offset
and disc_abs_offset+disc_size, returning Access error on overflow.
Mirrors the existing sequential_reader.rs::read_union_value pattern.

L3 (error.rs): AlkTypeError::source() now returns Some(inner) for the
Validation variant (jsonschema::ValidationError implements
std::error::Error). Existing source_returns_none_for_all_variants
test split into source_returns_none_for_schema_offset_access and
source_returns_some_for_validation_variant.

Deferred: L2 (unreachable! -> Err, defense-in-depth), N1 (non-strict
RFC 3339 validator, docs-only), N2 (FieldValue::Bytes for Record,
API asymmetry). Review doc updated with resolution section.
This commit is contained in:
2026-08-11 08:41:15 +00:00
parent ee6e773123
commit a975befdd1
5 changed files with 187 additions and 15 deletions

View File

@@ -1,5 +1,5 @@
---
status: open
status: resolved (M1, M2, L1, L3); open (L2, N1, N2)
last_updated: 2026-08-11
reviewed_artifacts:
- src/lib.rs
@@ -465,4 +465,70 @@ sanity check (README, inline docs, docs.rs render) and publish.
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.
coverage gaps, not correctness issues.
---
## Resolution (2026-08-11)
Four of the seven findings were resolved in the same session as the
review. 5 new tests added (391 → 396 crate tests; 438 → 443 total
with integration tests). `cargo test`, `cargo clippy --all-targets --
-D warnings`, and `cargo build --target wasm32-unknown-unknown
--release` all green.
### M2 (u32 truncation in `write_bytes`) — resolved
`src/data_access.rs`: added a `u32::try_from(data_len)` guard before
the `u32_to` call. A >4GiB blob now returns
`AlkTypeError::Access { reason: "data length N exceeds u32::MAX
(length prefix width)" }` instead of silently writing a truncated
length prefix. ~3 lines.
### M1 (`Definitions::merge_into` silent data loss) — resolved
`src/builder.rs`: rewrote `merge_into` to access `self.defs` directly
instead of round-tripping through `self.build()`. The double-
`cloned().unwrap_or_default().as_object().cloned().unwrap_or_default()`
chain is gone — the definitions are moved directly into the target's
`$defs` object. 4 new tests cover: insert-when-absent, merge-into-
existing, overwrite-duplicate-keys, and no-op-on-non-object-top.
~15 lines + ~50 lines of tests.
### L1 (materialize.rs unchecked offset arithmetic) — resolved
`src/materialize.rs`: the byte-offset discriminator arm of
`materialize_union_packed` now uses `checked_add` for both
`offset + disc_offset` and `disc_abs_offset + disc_size`, returning
`AlkTypeError::Access` on overflow. Mirrors the existing pattern in
`sequential_reader.rs::read_union_value`. The `variant_offset`
computation is now overflow-safe. ~15 lines.
### L3 (`AlkTypeError::source()` for `Validation`) — resolved
`src/error.rs`: replaced the blanket `impl std::error::Error for
AlkTypeError {}` with an explicit impl that returns `Some(inner)` for
the `Validation` variant and `None` for the others. The existing
`source_returns_none_for_all_variants` test was split into two:
`source_returns_none_for_schema_offset_access` (unchanged behavior)
and `source_returns_some_for_validation_variant` (new). ~6 lines +
~10 lines of tests.
### Deferred
- **L2** (`unreachable!` → `Err`): the three `unreachable!` sites are
genuinely unreachable today. Converting them to `Err` is defense-in-
depth against a future `AlkTypeKind` variant addition or a
`parse_discriminator` logic bug. Deferred until the "load untrusted
schemas" use case is on the roadmap — until then, the exhaustiveness
check is the safety net.
- **N1** (non-strict `is_rfc3339_timestamp`): documented as "simple"
in the existing doc comment. A strict implementation would add a
`chrono` or `time` dependency, not worth it for 0.1.0. Will add an
explicit "non-strict" note in the docs sweep.
- **N2** (`FieldValue::Bytes` for `Record`): API asymmetry, not a
bug. Revisit if the alkcall consumer finds it awkward.
After M1, M2, L1, and L3, the remaining open findings (L2, N1, N2)
are all deferrable. The crate is ready for the pre-publish docs sweep
(README, inline doc cleanup for docs.rs) and the final sanity check.

View File

@@ -489,14 +489,15 @@ impl Definitions {
/// Merge the `$defs` into a top-level schema `Value`. Convenience
/// for the common case of building a schema that references its
/// definitions.
/// definitions. If `top` already has a `$defs` object, the definitions
/// are merged into it (existing keys with the same name are
/// overwritten); otherwise a `$defs` key is inserted.
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()));
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));
}
}
}
@@ -649,6 +650,69 @@ mod tests {
);
}
#[test]
fn builder_merge_into_inserts_defs_when_absent() {
let mut defs = Definitions::new();
defs.define("Read", Schema::struct_().field("id", Schema::uint32()));
let mut top = Schema::struct_().field("payload", Schema::ref_def("Read")).build();
defs.merge_into(&mut top);
assert!(top["$defs"].is_object(), "$defs should be inserted");
assert_eq!(
top["$defs"]["Read"],
json!({"AlkType:Struct": true, "properties": {"id": {"AlkType:Uint32": true}}})
);
// The top-level schema's own keys are preserved.
assert_eq!(top["AlkType:Struct"], json!(true));
}
#[test]
fn builder_merge_into_merges_into_existing_defs() {
// Top already has a $defs with "Write"; merging in "Read" should
// add "Read" and keep "Write".
let mut top = Schema::struct_()
.field("payload", Schema::ref_def("Read"))
.build();
top["$defs"] = json!({
"Write": {"AlkType:Struct": true, "properties": {"n": {"AlkType:Uint16": true}}}
});
let mut defs = Definitions::new();
defs.define("Read", Schema::struct_().field("id", Schema::uint32()));
defs.merge_into(&mut top);
assert_eq!(
top["$defs"]["Write"],
json!({"AlkType:Struct": true, "properties": {"n": {"AlkType:Uint16": true}}}),
"existing Write def should be preserved"
);
assert_eq!(
top["$defs"]["Read"],
json!({"AlkType:Struct": true, "properties": {"id": {"AlkType:Uint32": true}}}),
"new Read def should be merged in"
);
}
#[test]
fn builder_merge_into_overwrites_duplicate_keys() {
// If both top and defs define the same key, the merged value wins.
let mut top = json!({
"AlkType:Struct": true,
"$defs": {"X": {"old": true}}
});
let mut defs = Definitions::new();
defs.define_value("X", json!({"new": true}));
defs.merge_into(&mut top);
assert_eq!(top["$defs"]["X"], json!({"new": true}));
}
#[test]
fn builder_merge_into_is_noop_for_non_object_top() {
// If top is not an object, merge_into silently does nothing.
let mut top = json!("not an object");
let mut defs = Definitions::new();
defs.define("Read", Schema::uint32());
defs.merge_into(&mut top);
assert_eq!(top, json!("not an object"), "non-object top should be untouched");
}
#[test]
fn builder_ref_def_produces_json_pointer() {
assert_eq!(

View File

@@ -262,6 +262,12 @@ pub fn write_bytes(
endian: Endian,
) -> Result<usize, AlkTypeError> {
let data_len = value.len();
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)"),
)
})?;
let total = U32_SIZE.checked_add(data_len).ok_or_else(|| {
access_err(
field_path,
@@ -275,7 +281,7 @@ pub fn write_bytes(
)
})?;
check_bounds(buffer.len(), offset, end, field_path)?;
write_array(buffer, offset, u32_to(data_len as u32, endian), field_path)?;
write_array(buffer, offset, u32_to(data_len_u32, endian), field_path)?;
let data_start = offset + U32_SIZE;
let dest = buffer.get_mut(data_start..end).ok_or_else(|| {
access_err(

View File

@@ -42,7 +42,14 @@ impl fmt::Display for AlkTypeError {
}
}
impl std::error::Error for AlkTypeError {}
impl std::error::Error for AlkTypeError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
AlkTypeError::Validation(inner) => Some(inner),
_ => None,
}
}
}
#[cfg(test)]
mod tests {
@@ -93,7 +100,7 @@ mod tests {
}
#[test]
fn source_returns_none_for_all_variants() {
fn source_returns_none_for_schema_offset_access() {
let schema_err = AlkTypeError::Schema("x".to_string());
assert!(std::error::Error::source(&schema_err).is_none());
let offset_err = AlkTypeError::Offset {
@@ -107,4 +114,17 @@ mod tests {
};
assert!(std::error::Error::source(&access_err).is_none());
}
#[test]
fn source_returns_some_for_validation_variant() {
// The Validation variant wraps a jsonschema::ValidationError<'static>
// which implements std::error::Error. source() should return Some.
let schema = serde_json::json!({"type": "integer"});
let validator = jsonschema::validator_for(&schema).expect("validator");
let instance = serde_json::json!("not-an-integer");
let js_err = validator.validate(&instance).expect_err("invalid");
let owned = js_err.to_owned();
let err = AlkTypeError::Validation(owned);
assert!(std::error::Error::source(&err).is_some(), "Validation variant should expose its source");
}
}

View File

@@ -311,15 +311,23 @@ fn materialize_union_packed(
offset: disc_offset,
disc_type,
} => {
let disc_abs_offset = offset.checked_add(disc_offset).ok_or_else(|| {
AlkTypeError::Access {
field_path: field_path.to_string(),
reason: format!(
"discriminator offset {offset} + {disc_offset} overflows usize"
),
}
})?;
let disc_value = match disc_type {
AlkTypeKind::Uint8 => {
data_access::read_u8(buffer, offset + disc_offset, field_path)? as u32
data_access::read_u8(buffer, disc_abs_offset, field_path)? as u32
}
AlkTypeKind::Uint16 => {
data_access::read_u16(buffer, offset + disc_offset, field_path, endian)? as u32
data_access::read_u16(buffer, disc_abs_offset, field_path, endian)? as u32
}
AlkTypeKind::Uint32 => {
data_access::read_u32(buffer, offset + disc_offset, field_path, endian)?
data_access::read_u32(buffer, disc_abs_offset, field_path, endian)?
}
_ => unreachable!("disc_type restricted by parse_discriminator"),
};
@@ -333,7 +341,15 @@ fn materialize_union_packed(
field_path: field_path.to_string(),
reason: format!("union discriminator value {key} not in mapping"),
})?;
let variant_offset = offset + disc_offset + disc_type.type_size().unwrap_or(1);
let disc_size = disc_type.type_size().unwrap_or(1);
let variant_offset = disc_abs_offset.checked_add(disc_size).ok_or_else(|| {
AlkTypeError::Access {
field_path: field_path.to_string(),
reason: format!(
"variant offset {disc_abs_offset} + disc size {disc_size} overflows usize"
),
}
})?;
let (variant_value, new_offset) = materialize_field_packed(
buffer,
root,