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:
@@ -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.
|
||||
@@ -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!(
|
||||
|
||||
@@ -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(
|
||||
|
||||
24
src/error.rs
24
src/error.rs
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user