Resolve N2: bound align annotations at 4096 (review #006)

align: 2^62 compiled and reported total_size = 2^63 — meaningless
layout output the consumer may act on, and the reachable path to the
MAX_ARRAY_BYTES cap used exactly this knob.

- MAX_ALIGN = 4096 (page granularity) in schema.rs, documented with the
  probe arithmetic
- parse_align returns Result and rejects over-cap values with a clean
  Schema error naming path/value/maximum — a silent clamp was rejected
  (it would change layout semantics without telling the consumer);
  both call sites thread the path, so standalone BastDoc::new (which
  never runs the meta-schema) is covered
- Meta-schema: "maximum": 4096 on StructDef.align and FieldDef.align —
  the published alk.dev/bast/v1/schema contract now matches the parser
- H1's byte-cap test retuned to align 4096 x count 2^16 = 2^28 > 2^26
  (the byte cap stays reachable under the new align cap)

Tests: 3 new (struct align above cap, field align above cap, align at
cap accepted). 511 tests green, clippy -D warnings clean, wasm32 build
green, cargo doc zero warnings.
This commit is contained in:
glm-5.3-flash committed 2026-09-02 19:46:51 +00:00
1 parent 8739d29550
commit 0bc5a541ac
5 files changed
+139 -21

No files matched your search

+41 -5
View File
@@ -1,5 +1,5 @@
---
status: in-progress (H1, L1, H3, L5, L6, H2, M1, M2, M3, L2, L3 resolved 2026-09-02)
status: in-progress (H1, L1, H3, L5, L6, H2, M1, M2, M3, L2, L3, N2 resolved 2026-09-02)
last_updated: 2026-09-02
reviewed_artifacts:
- src/read_plan.rs
@@ -98,10 +98,11 @@ sub-types, `fingerprint()` methods; `Hash` on `Endian`/
| High | 3 (H1, H2, H3) | all resolved 2026-09-02 |
| Medium | 4 (M1, M2, M3, M4) | M1, M2, M3 resolved 2026-09-02; M4 ongoing |
| Low | 6 (L1–L6) | L1, L2, L3, L5, L6 resolved 2026-09-02 |
| Nit | 2 (N1, N2) | open |
| Nit | 2 (N1, N2) | N2 resolved 2026-09-02; N1 open |
All three Highs, three of four Mediums, and five of six Lows are
resolved. Remaining: M4 (ongoing per-fix coverage posture), L4, N1, N2.
All three Highs, three of four Mediums, five of six Lows, and one of
two Nits are resolved. Remaining: M4 (ongoing per-fix coverage
posture), L4, N1.
The three Highs are adversarial-input crashes (H1, H2) and a
cross-consumer wire-layout convention gap (H3) — all three are the
@@ -141,6 +142,13 @@ them became materially easier to hit with the 0.3.0 surface.
0.3.0 compiled-form files already in play) — see the resolution
blocks on each finding. 508 tests green, clippy `-D warnings` clean,
wasm build green, `cargo doc --no-deps` zero warnings.
- **N2 (2026-09-02):** resolved — see the "Resolution (2026-09-02)"
block on the finding. `MAX_ALIGN = 4096` enforced in `parse_align`
(clean `Schema` error, standalone-safe) and in the meta-schema
(`"maximum": 4096` on both align properties); H1's byte-cap test
retuned to the new legal maximum. 511 tests green, clippy
`-D warnings` clean, wasm build green, `cargo doc --no-deps` zero
warnings.
---
@@ -954,6 +962,33 @@ the honest layouts in the wild never need >64.
(with a clean `Schema` error). A locking test (align above the cap →
clean `Err`) mirrors the H1 test family.
**Resolution (2026-09-02):** both layers, cap = 4096 (page granularity;
the finding's suggestion):
1. **`MAX_ALIGN = 4096`** added to `schema.rs` (documented with the
N2 probe arithmetic: `align: 2^62` → `total_size = 2^63`).
2. **`parse_align` now returns `Result<Option<usize>>`** and rejects
over-cap values with a clean `Schema` error naming the path, the
value, and the maximum — a clamp-with-silent-drop was considered
and rejected: it would change layout semantics without telling the
consumer (AGENTS.md §3 wants a handleable error, not a surprise).
Both call sites (`BastStruct::parse`, `BastField::parse`) thread the
path. This closes the standalone `BastDoc::new` path, which never
runs the meta-schema.
3. **Meta-schema `"maximum": 4096`** added to both `align` properties
(`StructDef.align`, `FieldDef.align`) — the published
`https://alk.dev/bast/v1/schema` contract now matches the parser.
4. **H1 byte-cap test retuned**: the old fixture used `align: 2^20` to
reach `MAX_ARRAY_BYTES`; it now uses `align: 4096` × count 2^16 =
2^28 > 2^26 — the byte cap stays reachable under the new align cap
(the finding's point (b) remains testable).
Tests: `n2_struct_align_above_cap_rejected_at_parse` (message names
align + maximum), `n2_field_align_above_cap_rejected_at_parse`,
`n2_align_at_cap_accepted` (boundary: align 4096 accepted, total_size
4096). Verified: 511 tests green, clippy `-D warnings` clean, wasm
build green, `cargo doc --no-deps` zero warnings.
---
## What's Good
@@ -1011,7 +1046,8 @@ Worth recording, because the findings shouldn't eclipse it:
7. **L1–L6, N1, N2** — opportunistic, folded into whichever session
touches the relevant file (L6 is the exception — it belongs with
H3; N2 pairs naturally with any bast/bast_meta session; L1 done
with H1; L5/L6 done with H3; L2/L3 done with the M-fix session).
with H1; L5/L6 done with H3; L2/L3 done with the M-fix session;
N2 done in the M-fix session's tail).
## Notes
+29 -8
View File
@@ -36,7 +36,7 @@
use crate::error::AlkTypeError;
use crate::schema::{
AlkTypeKind, Endian, VariableEncoding, MAX_ARRAY_ELEMENTS,
AlkTypeKind, Endian, VariableEncoding, MAX_ALIGN, MAX_ARRAY_ELEMENTS,
};
use serde_json::Value;
@@ -308,7 +308,7 @@ impl BastStruct {
fn parse(node: &Value, path: &str, doc_root: &Value) -> Result<Self, AlkTypeError> {
let endian = parse_endian_opt(node).unwrap_or(Endian::Little);
let align = parse_align(node);
let align = parse_align(node, path)?;
let raw_fields = node
.get("fields")
.and_then(Value::as_array)
@@ -427,7 +427,7 @@ impl BastField {
})?;
let ty = BastType::parse(raw_kind, path, doc_root)?;
let endian = parse_endian_opt(node);
let align = parse_align(node);
let align = parse_align(node, path)?;
let encoding = parse_encoding(node);
let max_length = parse_max_length(node);
Ok(Self {
@@ -993,11 +993,32 @@ fn parse_endian_opt(node: &Value) -> Option<Endian> {
}
}
fn parse_align(node: &Value) -> Option<usize> {
node.as_object()
.and_then(|o| o.get("align"))
.and_then(Value::as_u64)
.and_then(|n| usize::try_from(n).ok())
/// Parse a struct- or field-level `align` annotation. Over-sized values
/// are a clean `Schema` error (review #006 N2: an unbounded align let a
/// one-field schema declare an exabyte-scale layout — `align_up` rounds
/// the running offset by the full annotation).
fn parse_align(node: &Value, path: &str) -> Result<Option<usize>, AlkTypeError> {
let raw = match node.as_object().and_then(|o| o.get("align")) {
None | Some(Value::Null) => return Ok(None),
Some(v) => v,
};
let n = raw.as_u64().ok_or_else(|| {
AlkTypeError::Schema(format!(
"bast: align at {path} is not a non-negative integer"
))
})?;
let n = usize::try_from(n).map_err(|_| {
AlkTypeError::Schema(format!(
"bast: align at {path} (= {n}) overflows usize"
))
})?;
if n > MAX_ALIGN {
return Err(AlkTypeError::Schema(format!(
"bast: align at {path} (= {n}) exceeds the maximum of {MAX_ALIGN} \
(review #006 N2: honest layouts never need more than page granularity)"
)));
}
Ok(Some(n))
}
fn parse_max_length(node: &Value) -> Option<usize> {
+2 -2
View File
@@ -61,7 +61,7 @@ pub static BAST_META_SCHEMA: LazyLock<Value> = LazyLock::new(|| {
"properties": {
"kind": { "const": "struct" },
"endian": { "enum": ["little", "big"] },
"align": { "type": "integer", "minimum": 1 },
"align": { "type": "integer", "minimum": 1, "maximum": 4096 },
"fields": {
"type": "array",
"items": { "$ref": "#/$defs/FieldDef" }
@@ -76,7 +76,7 @@ pub static BAST_META_SCHEMA: LazyLock<Value> = LazyLock::new(|| {
"name": { "type": "string", "pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$" },
"kind": { "$ref": "#/$defs/TypeRef" },
"endian": { "enum": ["little", "big"] },
"align": { "type": "integer", "minimum": 1 },
"align": { "type": "integer", "minimum": 1, "maximum": 4096 },
"encoding": { "enum": ["length-prefixed", "offset-indirect"] },
"maxLength": { "type": "integer", "minimum": 0 }
},
+59 -6
View File
@@ -710,6 +710,58 @@ mod tests {
// ----- H1: array caps bound untrusted schemas at compute time ------
#[test]
fn n2_struct_align_above_cap_rejected_at_parse() {
let root = json!({
"$defs": {
"S": {
"kind": "struct",
"align": 4097u64,
"fields": [ { "name": "v", "kind": "uint8" } ]
}
}
});
let err = BastDoc::new(&root, "S").unwrap_err();
match err {
AlkTypeError::Schema(reason) => {
assert!(reason.contains("align"), "reason: {reason}");
assert!(reason.contains("maximum"), "reason: {reason}");
}
other => panic!("expected Schema error, got {other:?}"),
}
}
#[test]
fn n2_field_align_above_cap_rejected_at_parse() {
let root = json!({
"$defs": {
"S": {
"kind": "struct",
"fields": [
{ "name": "v", "kind": "uint8", "align": 8192 }
]
}
}
});
let err = BastDoc::new(&root, "S").unwrap_err();
assert!(matches!(err, AlkTypeError::Schema(_)), "got {err:?}");
}
#[test]
fn n2_align_at_cap_accepted() {
let root = json!({
"$defs": {
"S": {
"kind": "struct",
"align": 4096u64,
"fields": [ { "name": "v", "kind": "uint8" } ]
}
}
});
let m = map(&root, "S");
assert_eq!(m.total_size(), 4096);
}
#[test]
fn h1_array_count_above_cap_rejected_at_parse() {
let root = json!({
@@ -733,16 +785,17 @@ mod tests {
#[test]
fn h1_array_bytes_above_cap_rejected_at_compute() {
// 65536 elements × stride 2^20 (a u8 array in a struct with
// align 2^20) = 2^36 > 2^26 — hits the byte cap while staying
// under the element cap. (A struct/array element would be
// rejected earlier as variable-length, OQ-001, so the reachable
// way over the byte cap is a huge stride, not a huge element.)
// 65536 elements × stride 4096 (a u8 array in a struct with the
// maximum legal align, N2) = 2^28 > 2^26 — hits the byte cap
// while staying under the element cap. (A struct/array element
// would be rejected earlier as variable-length, OQ-001, so the
// reachable way over the byte cap is a large stride, not a huge
// element.)
let root = json!({
"$defs": {
"S": {
"kind": "struct",
"align": 1048576u64,
"align": 4096u64,
"fields": [
{ "name": "vals", "kind": { "kind": "array", "element": "uint8", "count": 65536 } }
]
+8
View File
@@ -30,6 +30,14 @@ pub(crate) const MAX_ARRAY_ELEMENTS: usize = 1 << 16;
/// a legal count into an unbounded layout request.
pub(crate) const MAX_ARRAY_BYTES: usize = 1 << 26;
/// Maximum declared `align` annotation (struct- or field-level).
/// Schemas are untrusted input (AGENTS.md §3): `align_up` rounds the
/// running offset by the full annotation, so an unbounded align lets a
/// one-field schema declare an exabyte-scale layout (`total_size = 2^63`
/// from `align: 2^62` — review #006 N2 probe). Honest layouts never
/// need more than page granularity (4096).
pub(crate) const MAX_ALIGN: usize = 4096;
/// The 18 BAST kinds recognized by the engine.
///
/// Each variant corresponds to a lowercase BAST kind string