Skip to main content

iris_guard/
check.rs

1//! The walk that decides whether a batch can be read.
2
3use arrow_schema::{DataType, Field, Schema};
4use iris_abi::Node;
5
6use crate::error::{Invariant, Result, Violation};
7use crate::layout::{Layout, layout, offset_width, slot_bits};
8
9/// How deep a schema is allowed to nest.
10///
11/// The number is not interesting and the bound is. Everything downstream of this crate walks a
12/// schema recursively, so a schema nested a hundred thousand deep is a stack overflow rather than an
13/// error, and a stack overflow is not something a host can catch and turn into a failed query. Sixty
14/// four is far past any schema anybody writes on purpose and far short of anything that threatens a
15/// stack.
16pub const MAX_DEPTH: usize = 64;
17
18/// Checks a schema on its own, before anything is read against it.
19///
20/// This is separate from [`check`] because it is worth doing once when a dataset is opened rather
21/// than once per batch, and because a host that is only inspecting a file still wants to know
22/// whether it holds a type this build can carry.
23///
24/// # Errors
25///
26/// Returns a violation of [`Invariant::Depth`] if the schema nests past [`MAX_DEPTH`], or of
27/// [`Invariant::Unsupported`] if it names a type this build cannot carry.
28pub fn check_schema(schema: &Schema) -> Result<()> {
29    // The walk is a worklist rather than a recursion, and that is the whole point of it. A
30    // recursive depth check overflows the stack on exactly the input it exists to reject, which is
31    // a check that works until it matters.
32    let mut work: Vec<(&Field, usize, String)> = schema
33        .fields()
34        .iter()
35        .map(|field| (field.as_ref(), 1, field.name().clone()))
36        .collect();
37
38    while let Some((field, depth, path)) = work.pop() {
39        if depth > MAX_DEPTH {
40            return Err(Violation::at(
41                Invariant::Depth,
42                &path,
43                format!("this build walks {MAX_DEPTH} levels of nesting and this is deeper"),
44            ));
45        }
46        let found = layout(field.data_type(), &path)?;
47        for child in found.children {
48            let child_path = format!("{path}.{}", child.name());
49            work.push((child, depth + 1, child_path));
50        }
51    }
52
53    Ok(())
54}
55
56/// Checks one batch against the schema it claims to be.
57///
58/// Everything here is a bounds question. If this returns `Ok` then every offset in the batch is
59/// inside the buffer it indexes, every buffer is long enough for the number of slots its array
60/// claims, and every array is long enough for the parent that points into it, so the arrays can be
61/// read without a read going anywhere it should not.
62///
63/// What it deliberately does not check is whether the values mean anything. A `Utf8` column whose
64/// bytes are not valid UTF-8 is refused later by Arrow, and it is refused there rather than here
65/// because character encoding is a correctness property and not a bounds property: reading a badly
66/// encoded string cannot leave the buffer. Splitting it that way keeps the fuzzed surface the one
67/// with the silent failure mode.
68///
69/// # Errors
70///
71/// Returns the first violation found, naming the rule and the path.
72pub fn check<B: AsRef<[u8]>>(
73    schema: &Schema,
74    rows: u64,
75    nodes: &[Node],
76    buffers: &[B],
77) -> Result<()> {
78    check_schema(schema)?;
79
80    let mut cursor = Cursor {
81        nodes,
82        buffers,
83        node: 0,
84        buffer: 0,
85    };
86
87    for field in schema.fields() {
88        let len = cursor.array(field, field.name())?;
89        if len != rows {
90            return Err(Violation::at(
91                Invariant::Rows,
92                field.name(),
93                format!("the batch says {rows} rows and this column has {len}"),
94            ));
95        }
96    }
97
98    cursor.finish()
99}
100
101/// A position in the batch's two flat lists.
102struct Cursor<'a, B> {
103    nodes: &'a [Node],
104    buffers: &'a [B],
105    node: usize,
106    buffer: usize,
107}
108
109impl<'a, B: AsRef<[u8]>> Cursor<'a, B> {
110    /// Checks one array and everything under it, returning how long it is.
111    fn array(&mut self, field: &Field, path: &str) -> Result<u64> {
112        let node = self.next_node(path)?;
113        let data_type = field.data_type();
114        let Layout {
115            validity,
116            values,
117            children,
118        } = layout(data_type, path)?;
119
120        if validity {
121            let bitmap = self.next_buffer(path)?;
122            check_validity(bitmap, node.length, node.null_count, path)?;
123        } else if node.null_count != 0 {
124            return Err(Violation::at(
125                Invariant::NullCount,
126                path,
127                format!(
128                    "a {data_type} column has no validity buffer and this one says it has {} nulls",
129                    node.null_count
130                ),
131            ));
132        }
133
134        let mut taken = Vec::with_capacity(values);
135        for _ in 0..values {
136            taken.push(self.next_buffer(path)?);
137        }
138
139        // Children are walked before the parent's own buffers are checked against them, because a
140        // list's offsets are only meaningful once its child's length is known.
141        let mut child_lengths = Vec::with_capacity(children.len());
142        for child in &children {
143            let child_path = format!("{path}.{}", child.name());
144            child_lengths.push(self.array(child, &child_path)?);
145        }
146
147        check_values(data_type, node.length, &taken, &child_lengths, path)?;
148        Ok(node.length)
149    }
150
151    fn next_node(&mut self, path: &str) -> Result<Node> {
152        let node = self.nodes.get(self.node).copied().ok_or_else(|| {
153            Violation::at(
154                Invariant::Arrays,
155                path,
156                format!(
157                    "the schema calls for more arrays than the batch has, which is {}",
158                    self.nodes.len()
159                ),
160            )
161        })?;
162        self.node += 1;
163        Ok(node)
164    }
165
166    fn next_buffer(&mut self, path: &str) -> Result<&'a [u8]> {
167        let bytes = self.buffers.get(self.buffer).ok_or_else(|| {
168            Violation::at(
169                Invariant::Buffers,
170                path,
171                format!(
172                    "the schema calls for more buffers than the batch has, which is {}",
173                    self.buffers.len()
174                ),
175            )
176        })?;
177        self.buffer += 1;
178        Ok(bytes.as_ref())
179    }
180
181    /// Checks that the batch had nothing left over.
182    ///
183    /// A batch with spare arrays or spare buffers is not harmless. It means this host and the
184    /// decoder disagree about the shape, and the next disagreement will be one where the counts
185    /// happen to line up and the contents do not.
186    fn finish(&self) -> Result<()> {
187        if self.node != self.nodes.len() {
188            return Err(Violation::at(
189                Invariant::Arrays,
190                "",
191                format!(
192                    "the batch has {} arrays and the schema accounts for {}",
193                    self.nodes.len(),
194                    self.node
195                ),
196            ));
197        }
198        if self.buffer != self.buffers.len() {
199            return Err(Violation::at(
200                Invariant::Buffers,
201                "",
202                format!(
203                    "the batch has {} buffers and the schema accounts for {}",
204                    self.buffers.len(),
205                    self.buffer
206                ),
207            ));
208        }
209        Ok(())
210    }
211}
212
213/// Checks a validity bitmap against the length and the null count that were declared alongside it.
214///
215/// An empty bitmap means every slot is present, which is how a decoder says a column has no nulls
216/// without paying for a buffer of ones.
217fn check_validity(bitmap: &[u8], len: u64, null_count: u64, path: &str) -> Result<()> {
218    if bitmap.is_empty() {
219        if null_count != 0 {
220            return Err(Violation::at(
221                Invariant::NullCount,
222                path,
223                format!(
224                    "there is no validity buffer and this array says it has {null_count} nulls"
225                ),
226            ));
227        }
228        return Ok(());
229    }
230
231    let needed = len.div_ceil(8);
232    let have = as_u64(bitmap.len());
233    if have < needed {
234        return Err(Violation::at(
235            Invariant::Validity,
236            path,
237            format!("{len} slots need {needed} bytes of validity and there are {have}"),
238        ));
239    }
240
241    // The null count is the one number in a batch that nothing else would catch. An array that says
242    // it has no nulls and hands over a bitmap of zeroes produces wrong answers rather than an
243    // error, which is the failure mode this whole crate exists for.
244    let counted = count_nulls(bitmap, len);
245    if counted != null_count {
246        return Err(Violation::at(
247            Invariant::NullCount,
248            path,
249            format!("this array says it has {null_count} nulls and its bitmap has {counted}"),
250        ));
251    }
252
253    Ok(())
254}
255
256/// How many of the first `len` bits are clear.
257fn count_nulls(bitmap: &[u8], len: u64) -> u64 {
258    let mut nulls = 0;
259    let mut seen = 0u64;
260    for byte in bitmap {
261        if seen >= len {
262            break;
263        }
264        let left = len - seen;
265        let bits = if left >= 8 {
266            8
267        } else {
268            u32::try_from(left).unwrap_or(8)
269        };
270        let mask: u8 = if bits == 8 {
271            u8::MAX
272        } else {
273            (1u8 << bits) - 1
274        };
275        nulls += u64::from(bits) - u64::from((byte & mask).count_ones());
276        seen += u64::from(bits);
277    }
278    nulls
279}
280
281/// Checks the buffers an array takes for itself, once its children are known.
282///
283/// One arm per shape, because the shapes have nothing in common: a string's offsets are bounded by
284/// a byte count, a list's by a row count, and a struct has no buffer of its own at all.
285fn check_values(
286    data_type: &DataType,
287    len: u64,
288    buffers: &[&[u8]],
289    child_lengths: &[u64],
290    path: &str,
291) -> Result<()> {
292    let child = child_lengths.first().copied().unwrap_or(0);
293
294    match data_type {
295        DataType::Null => Ok(()),
296        DataType::Utf8 | DataType::Binary | DataType::LargeUtf8 | DataType::LargeBinary => {
297            check_variable(data_type, len, buffers, path)
298        }
299        DataType::List(_) | DataType::LargeList(_) | DataType::Map(_, _) => {
300            check_list(data_type, len, buffers, child, path)
301        }
302        DataType::FixedSizeList(_, size) => check_fixed_size_list(len, *size, child, path),
303        DataType::Struct(fields) => {
304            for (field, child) in fields.iter().zip(child_lengths) {
305                if *child < len {
306                    return Err(Violation::at(
307                        Invariant::ChildLength,
308                        path,
309                        format!(
310                            "this struct has {len} rows and its {} field has {child}",
311                            field.name()
312                        ),
313                    ));
314                }
315            }
316            Ok(())
317        }
318        // Everything left is fixed width, because `layout` refused anything else before this ran.
319        other => check_fixed_width(other, len, buffers, path),
320    }
321}
322
323/// A string or a binary column: offsets into a buffer of bytes.
324fn check_variable(data_type: &DataType, len: u64, buffers: &[&[u8]], path: &str) -> Result<()> {
325    let [offsets, data] = buffers else {
326        return Err(counted_wrong(path, 2, buffers.len()));
327    };
328    let width = offset_width(data_type).expect("a variable length type has offsets");
329    let last = check_offsets(offsets, len, width, path)?;
330    let have = as_u64(data.len());
331    if last > have {
332        return Err(Violation::at(
333            Invariant::OffsetRange,
334            path,
335            format!("the last offset is {last} and the values buffer is {have} bytes"),
336        ));
337    }
338    Ok(())
339}
340
341/// A list or a map: offsets into a child array's slots rather than into bytes.
342fn check_list(
343    data_type: &DataType,
344    len: u64,
345    buffers: &[&[u8]],
346    child: u64,
347    path: &str,
348) -> Result<()> {
349    let [offsets] = buffers else {
350        return Err(counted_wrong(path, 1, buffers.len()));
351    };
352    let width = offset_width(data_type).expect("a list has offsets");
353    let last = check_offsets(offsets, len, width, path)?;
354    if last > child {
355        return Err(Violation::at(
356            Invariant::OffsetRange,
357            path,
358            format!("the last offset is {last} and the child array has {child} slots"),
359        ));
360    }
361    Ok(())
362}
363
364/// A fixed size list: no offsets at all, so the whole check is the multiplication.
365fn check_fixed_size_list(len: u64, size: i32, child: u64, path: &str) -> Result<()> {
366    let size = u64::try_from(size).map_err(|_| {
367        Violation::at(
368            Invariant::ChildLength,
369            path,
370            format!("a fixed size list cannot hold {size} values a row"),
371        )
372    })?;
373    let needed = len.checked_mul(size).ok_or_else(|| {
374        Violation::at(
375            Invariant::Size,
376            path,
377            format!("{len} rows of {size} values is more than this host can address"),
378        )
379    })?;
380    if child < needed {
381        return Err(Violation::at(
382            Invariant::ChildLength,
383            path,
384            format!("{len} rows of {size} values need {needed} slots and the child has {child}"),
385        ));
386    }
387    Ok(())
388}
389
390/// Everything whose slots are all the same width, which is most columns.
391fn check_fixed_width(data_type: &DataType, len: u64, buffers: &[&[u8]], path: &str) -> Result<()> {
392    let [values] = buffers else {
393        return Err(counted_wrong(path, 1, buffers.len()));
394    };
395    let bits = slot_bits(data_type).ok_or_else(|| {
396        Violation::at(
397            Invariant::Unsupported,
398            path,
399            format!("this build does not know how wide a {data_type} slot is"),
400        )
401    })?;
402    let needed = len
403        .checked_mul(bits)
404        .map(|total| total.div_ceil(8))
405        .ok_or_else(|| {
406            Violation::at(
407                Invariant::Size,
408                path,
409                format!("{len} slots of {bits} bits is more than this host can address"),
410            )
411        })?;
412    let have = as_u64(values.len());
413    if have < needed {
414        return Err(Violation::at(
415            Invariant::BufferLength,
416            path,
417            format!("{len} slots of {bits} bits need {needed} bytes and there are {have}"),
418        ));
419    }
420    Ok(())
421}
422
423/// Checks that a run of offsets is ordered, in range and long enough, and returns the last one.
424///
425/// The caller decides what the last offset has to be inside, because for a string it is a byte count
426/// and for a list it is a row count, and the two are not the same question.
427fn check_offsets(offsets: &[u8], len: u64, width: u64, path: &str) -> Result<u64> {
428    // A zero length array is allowed to hand over no offsets at all. Arrow permits it and a decoder
429    // that emits an empty batch should not have to allocate a buffer to say so.
430    if len == 0 && offsets.is_empty() {
431        return Ok(0);
432    }
433
434    // There is one more offset than there are slots, and that plus one is a real arithmetic
435    // operation rather than a formality. A length of `u64::MAX` wraps it to zero, which in a
436    // release build means no entries to check, no bytes needed, and an array of the largest length
437    // there is being accepted with an empty offsets buffer. The fuzzer found this one.
438    let entries = len.checked_add(1).ok_or_else(|| {
439        Violation::at(
440            Invariant::Size,
441            path,
442            format!("{len} slots need one more offset than that, which does not fit in a count"),
443        )
444    })?;
445    let needed = entries.checked_mul(width).ok_or_else(|| {
446        Violation::at(
447            Invariant::Size,
448            path,
449            format!("{entries} offsets of {width} bytes is more than this host can address"),
450        )
451    })?;
452    let have = as_u64(offsets.len());
453    if have < needed {
454        return Err(Violation::at(
455            Invariant::BufferLength,
456            path,
457            format!("{len} slots need {needed} bytes of offsets and there are {have}"),
458        ));
459    }
460
461    let mut previous: i64 = 0;
462    for index in 0..entries {
463        let at = usize::try_from(index * width).map_err(|_| {
464            Violation::at(
465                Invariant::Size,
466                path,
467                "the offsets run past what this host can address".to_owned(),
468            )
469        })?;
470        let offset = read_offset(offsets, at, width);
471
472        if offset < 0 {
473            return Err(Violation::at(
474                Invariant::OffsetRange,
475                path,
476                format!("offset {index} is {offset}, and an offset is a position"),
477            ));
478        }
479        if index > 0 && offset < previous {
480            return Err(Violation::at(
481                Invariant::OffsetOrder,
482                path,
483                format!("offset {index} is {offset} and the one before it is {previous}"),
484            ));
485        }
486        previous = offset;
487    }
488
489    u64::try_from(previous).map_err(|_| {
490        Violation::at(
491            Invariant::OffsetRange,
492            path,
493            "the last offset is negative".to_owned(),
494        )
495    })
496}
497
498/// Reads one offset of the given width. The caller has already checked the buffer is long enough.
499fn read_offset(bytes: &[u8], at: usize, width: u64) -> i64 {
500    if width == 8 {
501        let mut raw = [0u8; 8];
502        raw.copy_from_slice(&bytes[at..at + 8]);
503        i64::from_le_bytes(raw)
504    } else {
505        let mut raw = [0u8; 4];
506        raw.copy_from_slice(&bytes[at..at + 4]);
507        i64::from(i32::from_le_bytes(raw))
508    }
509}
510
511/// A length that came from a slice, which cannot be larger than a `u64` on any host iris runs on.
512fn as_u64(len: usize) -> u64 {
513    u64::try_from(len).unwrap_or(u64::MAX)
514}
515
516fn counted_wrong(path: &str, wanted: usize, found: usize) -> Violation {
517    Violation::at(
518        Invariant::Buffers,
519        path,
520        format!("this column takes {wanted} buffers after its validity buffer and got {found}"),
521    )
522}
523
524#[cfg(test)]
525mod tests {
526    use arrow_schema::{DataType, Field, Fields, Schema};
527    use iris_abi::Node;
528
529    use super::{MAX_DEPTH, check, check_schema, count_nulls};
530    use crate::error::Invariant;
531
532    fn node(length: u64, null_count: u64) -> Node {
533        Node { length, null_count }
534    }
535
536    fn i64s(values: &[i64]) -> Vec<u8> {
537        values.iter().flat_map(|v| v.to_le_bytes()).collect()
538    }
539
540    fn i32s(values: &[i32]) -> Vec<u8> {
541        values.iter().flat_map(|v| v.to_le_bytes()).collect()
542    }
543
544    #[test]
545    fn a_sound_batch_passes() {
546        let schema = Schema::new(vec![Field::new("a", DataType::Int64, false)]);
547        let buffers = vec![Vec::new(), i64s(&[1, 2, 3])];
548        check(&schema, 3, &[node(3, 0)], &buffers).expect("this batch is sound");
549    }
550
551    #[test]
552    fn a_column_shorter_than_the_batch_is_caught() {
553        let schema = Schema::new(vec![Field::new("a", DataType::Int64, false)]);
554        let buffers = vec![Vec::new(), i64s(&[1, 2])];
555        let err = check(&schema, 3, &[node(2, 0)], &buffers).expect_err("two is not three");
556        assert_eq!(err.invariant, Invariant::Rows);
557    }
558
559    #[test]
560    fn a_values_buffer_one_slot_short_is_caught() {
561        let schema = Schema::new(vec![Field::new("a", DataType::Int64, false)]);
562        let buffers = vec![Vec::new(), i64s(&[1, 2])];
563        let err =
564            check(&schema, 3, &[node(3, 0)], &buffers).expect_err("three slots need 24 bytes");
565        assert_eq!(err.invariant, Invariant::BufferLength);
566        assert!(err.to_string().contains("24 bytes"), "{err}");
567    }
568
569    #[test]
570    fn a_bitmap_with_too_few_bits_is_caught() {
571        let schema = Schema::new(vec![Field::new("a", DataType::Int64, true)]);
572        let buffers = vec![Vec::new(), i64s(&[1, 2, 3])];
573        // An empty bitmap is fine, so this uses one that is present and too short.
574        let short = vec![vec![0xffu8], i64s(&[1; 100])];
575        let wide = Schema::new(vec![Field::new("a", DataType::Int64, true)]);
576        let err = check(&wide, 100, &[node(100, 0)], &short).expect_err("100 slots need 13 bytes");
577        assert_eq!(err.invariant, Invariant::Validity);
578        check(&schema, 3, &[node(3, 0)], &buffers).expect("the empty bitmap case still passes");
579    }
580
581    #[test]
582    fn an_offset_one_past_the_end_is_caught() {
583        let schema = Schema::new(vec![Field::new("s", DataType::Utf8, false)]);
584        let buffers = vec![Vec::new(), i32s(&[0, 2, 6]), b"hoyea".to_vec()];
585        let err = check(&schema, 2, &[node(2, 0)], &buffers).expect_err("six is past five");
586        assert_eq!(err.invariant, Invariant::OffsetRange);
587    }
588
589    #[test]
590    fn offsets_that_run_backwards_are_caught() {
591        let schema = Schema::new(vec![Field::new("s", DataType::Utf8, false)]);
592        let buffers = vec![Vec::new(), i32s(&[0, 4, 2]), b"hoyea".to_vec()];
593        let err = check(&schema, 2, &[node(2, 0)], &buffers).expect_err("two is less than four");
594        assert_eq!(err.invariant, Invariant::OffsetOrder);
595    }
596
597    #[test]
598    fn a_child_one_row_short_of_its_parent_is_caught() {
599        let children = Fields::from(vec![Field::new("x", DataType::Int64, false)]);
600        let schema = Schema::new(vec![Field::new("p", DataType::Struct(children), false)]);
601        let buffers = vec![Vec::new(), Vec::new(), i64s(&[1, 2])];
602        let err = check(&schema, 3, &[node(3, 0), node(2, 0)], &buffers)
603            .expect_err("a struct's child cannot be shorter than the struct");
604        assert_eq!(err.invariant, Invariant::ChildLength);
605    }
606
607    #[test]
608    fn a_length_that_overflows_a_width_is_caught_rather_than_wrapped() {
609        let schema = Schema::new(vec![Field::new("a", DataType::Int64, false)]);
610        let buffers = vec![Vec::new(), i64s(&[1])];
611        let err = check(&schema, u64::MAX, &[node(u64::MAX, 0)], &buffers)
612            .expect_err("that many slots is not addressable");
613        assert_eq!(err.invariant, Invariant::Size);
614    }
615
616    /// The fuzzer found this one, on the second batch it generated with a corrupted length.
617    ///
618    /// A column of `u64::MAX` slots wrapped the count of offsets to zero, so the buffer needed no
619    /// bytes, the loop over the offsets ran no times, and the largest array there is came back
620    /// sound with an empty offsets buffer. It panicked in a debug build and was silent in a release
621    /// one, which is the wrong way round for a check.
622    #[test]
623    fn a_length_that_wraps_the_count_of_offsets_is_caught() {
624        for data_type in [DataType::Binary, DataType::LargeBinary] {
625            let schema = Schema::new(vec![Field::new("a", data_type, false)]);
626            let buffers = vec![Vec::new(), Vec::new(), Vec::new()];
627            let err = check(&schema, u64::MAX, &[node(u64::MAX, 0)], &buffers)
628                .expect_err("one more offset than that does not fit in a count");
629            assert_eq!(err.invariant, Invariant::Size);
630        }
631    }
632
633    #[test]
634    fn a_schema_nested_past_the_bound_is_refused_without_recursing_into_it() {
635        let mut data_type = DataType::Int64;
636        for _ in 0..MAX_DEPTH + 10 {
637            data_type = DataType::List(std::sync::Arc::new(Field::new("item", data_type, false)));
638        }
639        let schema = Schema::new(vec![Field::new("deep", data_type, false)]);
640        let err = check_schema(&schema).expect_err("that is deeper than this build walks");
641        assert_eq!(err.invariant, Invariant::Depth);
642    }
643
644    #[test]
645    fn a_schema_at_the_bound_is_still_walked() {
646        let mut data_type = DataType::Int64;
647        for _ in 0..MAX_DEPTH - 1 {
648            data_type = DataType::List(std::sync::Arc::new(Field::new("item", data_type, false)));
649        }
650        let schema = Schema::new(vec![Field::new("deep", data_type, false)]);
651        check_schema(&schema).expect("this is exactly as deep as the bound allows");
652    }
653
654    #[test]
655    fn spare_buffers_are_an_error_rather_than_something_ignored() {
656        let schema = Schema::new(vec![Field::new("a", DataType::Int64, false)]);
657        let buffers = vec![Vec::new(), i64s(&[1, 2, 3]), i64s(&[4])];
658        let err =
659            check(&schema, 3, &[node(3, 0)], &buffers).expect_err("a spare buffer is not fine");
660        assert_eq!(err.invariant, Invariant::Buffers);
661    }
662
663    #[test]
664    fn counting_nulls_stops_at_the_length_rather_than_the_byte() {
665        // Five slots, all present, in a byte whose top three bits are clear.
666        assert_eq!(count_nulls(&[0b0001_1111], 5), 0);
667        assert_eq!(count_nulls(&[0b0001_1110], 5), 1);
668        assert_eq!(count_nulls(&[0x00, 0xff], 9), 8);
669    }
670}