Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions lib/tests/tests/issues/obe_10735_array_index_cap.vrl
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# issue: OBE-10735
# Assigning to a large array index pads the array with `Value::Null` up to that index. The padding
# is observable semantics (`length` sees it), so the write genuinely commits `index + 1` elements —
# an event-controlled index was enough to exhaust memory. Indices beyond +/-1048576 (2^20) are now
# dropped. 2^20 bounds one indexed write to ~42 MB at today's 40-byte `Value`.
# result: [0, 1048577]

capped = []
capped[2000000] = 1

allowed = []
allowed[1048576] = 1

[length(capped), length(allowed)]
59 changes: 47 additions & 12 deletions src/value/value/crud/insert.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,13 @@ pub fn insert<'a, T: ValueCollection>(
if let Some(Value::Array(array)) = value.get_mut_value(key.borrow()) {
insert(array, index, path_iter, insert_value)
} else {
const MAX_ARRAY_CAPACITY: usize = 32_769;
let capacity = if index >= 0 {
((index as usize) + 1).min(MAX_ARRAY_CAPACITY)
} else {
((-index) as usize).min(MAX_ARRAY_CAPACITY)
};
let mut array = Vec::with_capacity(capacity);
// No preallocation here: `insert_value` (for `Vec<Value>`) checks the index
// against `MAX_ARRAY_INDEX` before doing any allocation, so an out-of-range
// index is rejected with zero large allocation. For an in-range index it does
// its own correctly-sized allocation (a growth loop for positive indices, a
// `with_capacity` for negative ones) — preallocating here duplicated or wasted
// that work.
let mut array = Vec::new();
let prev_value = insert(&mut array, index, path_iter, insert_value);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is already validation for max capacity here

fn insert_value(&mut self, key: isize, value: Value) -> Option<Value> {
115:     let max_index = MAX_ARRAY_INDEX as isize;
116:     if !(-max_index..=max_index).contains(&key) {
117:         // TODO: VRL-side array-index assignment is currently infallible (see
118:         // compiler::expression::assignment::Target::insert), so we can't surface this as a
119:         // proper VRL runtime error without a larger change. Log it so it's at least
120:         // observable instead of a silent no-op.
121:         tracing::warn!(
122:             index = key,
123:             max_index = MAX_ARRAY_INDEX,
124:             "array index assignment out of range, write dropped"
125:         );
126:         return None;
127:     }

value.insert_value(key, Value::Array(array));
prev_value
Expand Down Expand Up @@ -84,24 +84,59 @@ mod test {
#[test]
fn test_insert_beyond_max_array_index_is_rejected() {
let mut value = Value::Null;
assert_eq!(value.insert("[40000]", 1), None);
assert_eq!(value.insert("[1048577]", 1), None);
assert_eq!(value, Value::from(json!([])));
}

#[test]
fn test_insert_beyond_max_negative_array_index_is_rejected() {
let mut value = Value::Null;
assert_eq!(value.insert("[-40000]", 1), None);
assert_eq!(value.insert("[-1048577]", 1), None);
assert_eq!(value, Value::from(json!([])));
}

// OBE-10735: `insert` used to speculatively `Vec::with_capacity` the (clamped) index before
// `insert_value` had a chance to reject an out-of-range write, so a rejected huge index still
// committed a large (~42 MB) allocation. Assert the rejected array stays unallocated.
#[test]
fn test_insert_beyond_max_array_index_does_not_preallocate() {
let mut value = Value::Null;
assert_eq!(value.insert("[2000000]", 1), None);
let array = value.as_array_mut().expect("expected an array");
assert_eq!(array.capacity(), 0);
}

#[test]
fn test_insert_at_max_array_index_is_allowed() {
let mut value = Value::Null;
assert_eq!(value.insert("[32768]", 1), None);
assert_eq!(value.insert("[1048576]", 1), None);
let array = value.as_array().expect("expected an array");
assert_eq!(array.len(), 32769);
assert_eq!(array[32768], Value::Integer(1));
assert_eq!(array.len(), 1_048_577);
assert_eq!(array[1_048_576], Value::Integer(1));
}

// OBE-10735: the capacity calculation negated the index with `(-index) as usize`, which
// overflows on `isize::MIN` (there is no positive `isize` counterpart). `unsigned_abs` is
// the total operation.
#[test]
fn test_insert_at_isize_min_does_not_panic() {
let mut value = Value::Null;
let path = vec![BorrowedSegment::Index(isize::MIN)].into_iter();
assert_eq!(insert(&mut value, (), path, Value::Integer(1)), None);
assert_eq!(value, Value::from(json!([])));
}

// Drift detector, not a correctness assertion: the cap is justified in terms of the memory a
// single indexed write may commit (`MAX_ARRAY_INDEX + 1` elements of this size, ~42 MB today).
// If `Value` grows a variant, that budget changes and the cap deserves a fresh look.
#[test]
fn test_value_size_is_pinned() {
assert_eq!(
std::mem::size_of::<Value>(),
40,
"size_of::<Value>() changed; re-check the MAX_ARRAY_INDEX memory budget \
(cap x size = worst-case allocation for one indexed write)"
);
}

#[test]
Expand Down
8 changes: 6 additions & 2 deletions src/value/value/crud/mod.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
use crate::value::{KeyString, ObjectMap, Value};
use std::borrow::Borrow;

/// Largest array index `insert_value` will grow an array to, in either direction.
/// Largest array index an indexed write will grow an array to, in either direction.
/// Prevents an event-controlled index (e.g. `.foo[40000000] = 1`) from exhausting memory.
const MAX_ARRAY_INDEX: usize = 32_768;
///
/// Assigning to index `N` materialises `N + 1` elements — the null padding is observable VRL
/// semantics, not just a preallocation — so this cap is what bounds the memory a single write may
/// commit: ~42 MB at today's 40-byte `Value` (see `test_value_size_is_pinned`).
pub(super) const MAX_ARRAY_INDEX: usize = 1_048_576;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Incremented to give more headroom


mod get;
mod get_mut;
Expand Down