Skip to content

Commit dd96527

Browse files
committed
Merge branch 'security': robust parsing invariants and auth cache poison prevention
Reconciles security's UTF-8/panic-safety parsing hardening with main's DDL parser refactor (collection_type split into a directory module; CREATE ... INDEX parsing unified through the shared options grammar). - collection_type: main moved build_strict_schema -> strict.rs and parse_column_type_str_full -> type_str.rs carrying the old panic-prone byte slicing; ported security's hardening there (find_ascii_case_insensitive for GENERATED / PRIMARY KEY / NOT NULL / DEFAULT, safe .get(..) slicing, stronger unicode test). - search_index: main deleted the standalone parser; the ANALYZER name is now tokenized by the shared options lexer, which already slices on char boundaries (quote.len_utf8()), so security's parse_analyzer_name fix is subsumed. Preserved its regression intent with a unicode-quoted-value test in options/lex.rs.
2 parents 87053aa + 7bffe2a commit dd96527

112 files changed

Lines changed: 3270 additions & 1184 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/test.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,10 @@ jobs:
7474
run: |
7575
python3 scripts/ci/check_authorized_dispatch.py --self-test
7676
python3 scripts/ci/check_authorized_dispatch.py
77+
- name: Robust-parsing gate
78+
run: |
79+
python3 scripts/ci/check_robust_parsing.py --self-test
80+
python3 scripts/ci/check_robust_parsing.py
7781
- name: Install cargo-deny
7882
uses: taiki-e/install-action@v2
7983
with:

Cargo.lock

Lines changed: 1 addition & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,9 @@ nodedb-test-support = { path = "nodedb-test-support" }
6565
# Async runtimes
6666
tokio = { version = "1", features = ["full"] }
6767

68+
# Poison-free synchronization for security-critical in-memory state.
69+
parking_lot = "0.12"
70+
6871
# Error handling & diagnostics
6972
thiserror = "2.0"
7073
tracing = "0.1"

nodedb-array/src/codec/column_codec.rs

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
use nodedb_codec::error::CodecError;
1212
use nodedb_types::Surrogate;
1313

14-
use crate::codec::limits::{MAX_COLUMN_ENTRIES, check_decoded_size};
14+
use crate::codec::limits::{MAX_COLUMN_ENTRIES, checked_capacity};
1515
use crate::error::{ArrayError, ArrayResult};
1616
use crate::types::cell_value::value::CellValue;
1717

@@ -182,9 +182,15 @@ pub fn decode_attr_col(data: &[u8]) -> ArrayResult<Vec<CellValue>> {
182182
.try_into()
183183
.expect("invariant: bounds-checked above (body.len() >= 4)"),
184184
) as usize;
185-
check_decoded_size(count, MAX_COLUMN_ENTRIES, "attr_col_msgpack count")?;
185+
let capacity = checked_capacity(
186+
count,
187+
MAX_COLUMN_ENTRIES,
188+
body.len() - 4,
189+
4,
190+
"attr_col_msgpack count",
191+
)?;
186192
let mut pos = 4;
187-
let mut values = Vec::with_capacity(count);
193+
let mut values = Vec::with_capacity(capacity);
188194
for _ in 0..count {
189195
if pos + 4 > body.len() {
190196
return Err(ArrayError::SegmentCorruption {
@@ -300,6 +306,17 @@ mod tests {
300306
assert_eq!(out, vals);
301307
}
302308

309+
#[test]
310+
fn huge_msgpack_count_in_tiny_payload_is_rejected_before_allocation() {
311+
let mut data = vec![ATTR_TAG_MSGPACK];
312+
data.extend_from_slice(&(MAX_COLUMN_ENTRIES as u32).to_le_bytes());
313+
let result = decode_attr_col(&data);
314+
assert!(matches!(
315+
result,
316+
Err(crate::error::ArrayError::SegmentCorruption { .. })
317+
));
318+
}
319+
303320
#[test]
304321
fn attr_col_empty_roundtrip() {
305322
let data = encode_attr_col(&[]).unwrap();

nodedb-array/src/codec/coord_delta.rs

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@
1414
// [u32 LE] index count (= cell count)
1515
// [zigzag-varint deltas ...] index deltas (first value as-is, then deltas)
1616

17-
use crate::codec::limits::{MAX_CELLS_PER_TILE, MAX_DICT_CARDINALITY, check_decoded_size};
17+
use crate::codec::limits::{
18+
MAX_CELLS_PER_TILE, MAX_DICT_CARDINALITY, check_decoded_size, checked_capacity,
19+
};
1820
use crate::error::{ArrayError, ArrayResult};
1921
use crate::tile::sparse_tile::DimDict;
2022
use crate::types::coord::value::CoordValue;
@@ -150,9 +152,15 @@ pub fn decode_coord_axis(data: &[u8], pos: &mut usize) -> ArrayResult<DimDict> {
150152
.expect("invariant: preceding bounds check guarantees 4 bytes available"),
151153
) as usize;
152154
*pos += 4;
153-
check_decoded_size(dict_count, MAX_DICT_CARDINALITY, "coord_delta dict_count")?;
155+
let capacity = checked_capacity(
156+
dict_count,
157+
MAX_DICT_CARDINALITY,
158+
data.len() - *pos,
159+
4,
160+
"coord_delta dict_count",
161+
)?;
154162

155-
let mut out: Vec<CoordValue> = Vec::with_capacity(dict_count);
163+
let mut out: Vec<CoordValue> = Vec::with_capacity(capacity);
156164
for _ in 0..dict_count {
157165
if *pos + 4 > data.len() {
158166
return Err(ArrayError::SegmentCorruption {
@@ -238,9 +246,15 @@ pub fn decode_coord_axis(data: &[u8], pos: &mut usize) -> ArrayResult<DimDict> {
238246
.expect("invariant: preceding bounds check guarantees 4 bytes available"),
239247
) as usize;
240248
*pos += 4;
241-
check_decoded_size(idx_count, MAX_CELLS_PER_TILE, "coord_delta idx_count")?;
249+
let capacity = checked_capacity(
250+
idx_count,
251+
MAX_CELLS_PER_TILE,
252+
data.len() - *pos,
253+
1,
254+
"coord_delta idx_count",
255+
)?;
242256

243-
let mut indices: Vec<u32> = Vec::with_capacity(idx_count);
257+
let mut indices: Vec<u32> = Vec::with_capacity(capacity);
244258
let mut prev: i64 = 0;
245259
for _ in 0..idx_count {
246260
let (zz, consumed) =
@@ -363,6 +377,18 @@ mod tests {
363377
assert_eq!(out.values, d.values);
364378
}
365379

380+
#[test]
381+
fn huge_msgpack_dictionary_count_in_tiny_payload_is_rejected_before_allocation() {
382+
let mut data = Vec::new();
383+
data.push(DICT_TAG_MSGPACK);
384+
data.extend_from_slice(&(MAX_DICT_CARDINALITY as u32).to_le_bytes());
385+
let result = decode_coord_axis(&data, &mut 0);
386+
assert!(matches!(
387+
result,
388+
Err(crate::error::ArrayError::SegmentCorruption { .. })
389+
));
390+
}
391+
366392
#[test]
367393
fn zero_tag_byte_is_corruption() {
368394
// Tag 0 is reserved/forbidden. A payload starting with 0x00 must

nodedb-array/src/codec/coord_rle.rs

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
use crate::codec::coord_delta::{decode_coord_axis, encode_coord_axis};
1919
use crate::codec::limits::{
2020
MAX_CELLS_PER_TILE, MAX_DICT_CARDINALITY, MAX_RLE_RUN_LEN, MAX_RLE_RUNS, check_decoded_size,
21+
checked_capacity,
2122
};
2223
use crate::error::{ArrayError, ArrayResult};
2324
use crate::tile::sparse_tile::DimDict;
@@ -127,7 +128,13 @@ fn decode_rle(data: &[u8], pos: &mut usize) -> ArrayResult<DimDict> {
127128
.expect("invariant: bounds check at line 113 guarantees 4 bytes available"),
128129
) as usize;
129130
*pos += 4;
130-
check_decoded_size(run_count, MAX_RLE_RUNS, "rle run_count")?;
131+
checked_capacity(
132+
run_count,
133+
MAX_RLE_RUNS,
134+
data.len() - *pos,
135+
8,
136+
"rle run_count",
137+
)?;
131138

132139
let mut indices: Vec<u32> = Vec::new();
133140
let mut total_len: usize = 0;
@@ -167,9 +174,15 @@ fn decode_rle(data: &[u8], pos: &mut usize) -> ArrayResult<DimDict> {
167174
.expect("invariant: bounds check at line 143 guarantees 4 bytes available"),
168175
) as usize;
169176
*pos += 4;
170-
check_decoded_size(dict_count, MAX_DICT_CARDINALITY, "rle dict_count")?;
177+
let capacity = checked_capacity(
178+
dict_count,
179+
MAX_DICT_CARDINALITY,
180+
data.len() - *pos,
181+
4,
182+
"rle dict_count",
183+
)?;
171184

172-
let mut values: Vec<CoordValue> = Vec::with_capacity(dict_count);
185+
let mut values: Vec<CoordValue> = Vec::with_capacity(capacity);
173186
for _ in 0..dict_count {
174187
if *pos + 4 > data.len() {
175188
return Err(ArrayError::SegmentCorruption {
@@ -215,6 +228,18 @@ mod tests {
215228
decode_coord_axis_rle(&buf, &mut pos).unwrap()
216229
}
217230

231+
#[test]
232+
fn huge_run_count_in_tiny_payload_is_rejected_before_allocation() {
233+
let mut data = Vec::new();
234+
data.extend_from_slice(&RLE_MARKER.to_le_bytes());
235+
data.extend_from_slice(&(MAX_RLE_RUNS as u32).to_le_bytes());
236+
let result = decode_coord_axis_rle(&data, &mut 0);
237+
assert!(matches!(
238+
result,
239+
Err(crate::error::ArrayError::SegmentCorruption { .. })
240+
));
241+
}
242+
218243
#[test]
219244
fn empty_dict_roundtrip() {
220245
let d = make_dict(vec![], vec![]);

nodedb-array/src/codec/limits.rs

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,12 +43,60 @@ pub const MAX_COLUMN_ENTRIES: usize = MAX_CELLS_PER_TILE;
4343
/// Reject a length value that exceeds `cap`. Used everywhere a `usize`
4444
/// length is decoded from segment bytes.
4545
pub fn check_decoded_size(value: usize, cap: usize, what: &str) -> ArrayResult<()> {
46+
checked_capacity(value, cap, 0, 0, what).map(|_| ())
47+
}
48+
49+
/// Validate a decoded collection count before it drives an allocation.
50+
///
51+
/// `min_encoded_bytes` is the smallest on-wire representation per entry. It
52+
/// rejects a tiny frame carrying a huge count before reserving memory, while
53+
/// the caller still validates each variable-sized entry as it is consumed.
54+
pub fn checked_capacity(
55+
value: usize,
56+
cap: usize,
57+
remaining_bytes: usize,
58+
min_encoded_bytes: usize,
59+
what: &str,
60+
) -> ArrayResult<usize> {
4661
if value > cap {
4762
return Err(ArrayError::SegmentCorruption {
4863
detail: format!(
4964
"decoded {what} = {value} exceeds hard cap {cap} (segment likely corrupt)"
5065
),
5166
});
5267
}
53-
Ok(())
68+
let minimum_bytes =
69+
value
70+
.checked_mul(min_encoded_bytes)
71+
.ok_or_else(|| ArrayError::SegmentCorruption {
72+
detail: format!("decoded {what} minimum encoded size overflows"),
73+
})?;
74+
if minimum_bytes > remaining_bytes {
75+
return Err(ArrayError::SegmentCorruption {
76+
detail: format!(
77+
"decoded {what} requires at least {minimum_bytes} encoded bytes, only {remaining_bytes} remain"
78+
),
79+
});
80+
}
81+
Ok(value)
82+
}
83+
84+
#[cfg(test)]
85+
mod tests {
86+
use super::*;
87+
88+
#[test]
89+
fn checked_capacity_rejects_count_without_minimum_encoded_bytes() {
90+
assert!(checked_capacity(2, 8, 7, 4, "test entries").is_err());
91+
}
92+
93+
#[test]
94+
fn checked_capacity_rejects_count_over_hard_cap() {
95+
assert!(checked_capacity(9, 8, 36, 4, "test entries").is_err());
96+
}
97+
98+
#[test]
99+
fn checked_capacity_returns_validated_count() {
100+
assert_eq!(checked_capacity(2, 8, 8, 4, "test entries").unwrap(), 2);
101+
}
54102
}

nodedb-array/src/codec/tile_decode.rs

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ use crate::codec::column_codec::{
1212
};
1313
use crate::codec::coord_rle::decode_coord_axis_rle;
1414
use crate::codec::limits::{
15-
MAX_ATTRS_PER_TILE, MAX_AXES_PER_TILE, MAX_CELLS_PER_TILE, check_decoded_size,
15+
MAX_ATTRS_PER_TILE, MAX_AXES_PER_TILE, MAX_CELLS_PER_TILE, check_decoded_size, checked_capacity,
1616
};
1717
use crate::codec::tag::{CodecTag, peek_tag};
1818
use crate::error::{ArrayError, ArrayResult};
@@ -108,10 +108,16 @@ fn decode_structural(body: &[u8]) -> ArrayResult<SparseTile> {
108108
.expect("invariant: bounds-checked above (pos + 4 <= body.len())"),
109109
) as usize;
110110
pos += 4;
111-
check_decoded_size(axis_count, MAX_AXES_PER_TILE, "axis_count")?;
111+
let axis_capacity = checked_capacity(
112+
axis_count,
113+
MAX_AXES_PER_TILE,
114+
body.len() - pos,
115+
4,
116+
"axis_count",
117+
)?;
112118

113119
// Coordinate axes.
114-
let mut dim_dicts = Vec::with_capacity(axis_count);
120+
let mut dim_dicts = Vec::with_capacity(axis_capacity);
115121
for _ in 0..axis_count {
116122
let axis_bytes = read_framed(body, &mut pos)?;
117123
let mut inner_pos = 0;
@@ -150,9 +156,15 @@ fn decode_structural(body: &[u8]) -> ArrayResult<SparseTile> {
150156
.expect("invariant: bounds-checked above (pos + 4 <= body.len())"),
151157
) as usize;
152158
pos += 4;
153-
check_decoded_size(attr_count, MAX_ATTRS_PER_TILE, "attr_count")?;
154-
155-
let mut attr_cols = Vec::with_capacity(attr_count);
159+
let attr_capacity = checked_capacity(
160+
attr_count,
161+
MAX_ATTRS_PER_TILE,
162+
body.len() - pos,
163+
4,
164+
"attr_count",
165+
)?;
166+
167+
let mut attr_cols = Vec::with_capacity(attr_capacity);
156168
for _ in 0..attr_count {
157169
let col_bytes = read_framed(body, &mut pos)?;
158170
let col = decode_attr_col(col_bytes)?;
@@ -302,6 +314,15 @@ mod tests {
302314
assert_eq!(out.row_kinds, tile.row_kinds);
303315
}
304316

317+
#[test]
318+
fn huge_axis_count_in_tiny_structural_payload_is_rejected_before_allocation() {
319+
let mut body = Vec::new();
320+
body.extend_from_slice(&0u32.to_le_bytes());
321+
body.extend_from_slice(&(MAX_AXES_PER_TILE as u32).to_le_bytes());
322+
let error = decode_structural(&body).unwrap_err();
323+
assert!(matches!(error, ArrayError::SegmentCorruption { .. }));
324+
}
325+
305326
#[test]
306327
fn invalid_version_returns_error() {
307328
let s = schema();

nodedb-codec/src/alp.rs

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,8 @@
2727
use std::mem::size_of;
2828

2929
use crate::bounds::{
30-
checked_add, checked_mul, checked_range, decoded_len, encode_input_len, encode_u32_len,
31-
u32_to_usize,
30+
checked_add, checked_capacity, checked_mul, checked_range, decoded_len, encode_input_len,
31+
encode_u32_len, u32_to_usize,
3232
};
3333
use crate::error::CodecError;
3434
use crate::fastlanes;
@@ -239,7 +239,12 @@ pub fn decode(data: &[u8]) -> Result<Vec<f64>, CodecError> {
239239
let exceptions_end = checked_add(HEADER_SIZE, exceptions_size, "ALP exceptions")?;
240240
checked_range(data, HEADER_SIZE, exceptions_size, "ALP exceptions")?;
241241

242-
let mut exceptions = Vec::with_capacity(exception_count);
242+
let exception_capacity = checked_capacity(
243+
exception_count,
244+
size_of::<(usize, u64)>(),
245+
"ALP exception allocation",
246+
)?;
247+
let mut exceptions = Vec::with_capacity(exception_capacity);
243248
let mut pos = HEADER_SIZE;
244249
let mut previous_index = None;
245250
for _ in 0..exception_count {
@@ -283,7 +288,8 @@ pub fn decode(data: &[u8]) -> Result<Vec<f64>, CodecError> {
283288
}
284289

285290
// Reconstruct f64 values using the same decode mode that was selected during encode.
286-
let mut values = Vec::with_capacity(count);
291+
let value_capacity = checked_capacity(count, size_of::<f64>(), "ALP values")?;
292+
let mut values = Vec::with_capacity(value_capacity);
287293
for &int_val in &encoded_ints {
288294
values.push(alp_decode_value(int_val, decode_exp, mode));
289295
}

0 commit comments

Comments
 (0)