Skip to content

S24: Indexing (v[i], s[a..b]) on untrusted input

S24Severity: High

Out-of-range input makes the program panic.

Clippy: indexing_slicing, string_slice

A line from a partner’s CSV upload: id,sku,quantity. The first three characters of the SKU are the category.

pub fn parse_line(line: &str) -> Line<'_> {
let fields: Vec<&str> = line.split(',').collect();
Line {
id: fields[0],
category: &fields[1][..3],
quantity: fields[2],
}
}
  • It is the Java code, translated. fields[1].substring(0, 3) becomes &fields[1][..3]. The sample data in the prompt is always well-formed, so the code passes the tests.
  • Indexing looks safe because Rust is “memory safe”. It is: an out-of-range index never reads wrong memory. But it panics, and a panic is a crash of the thread.

Each index is a panic that one bad line can trigger:

Input line What happens
7,BK-1234,2 Works.
7,BK-1234 fields[2] panics: index out of bounds.
7,BK,2 [..3] panics: byte index 3 is out of bounds.
7,BKü-1,2 [..3] panics: byte index 3 is not a char boundary. “ü” is two bytes.

All three panics are tests in examples/smells/src/s24_indexing_untrusted_input.rs. The last one is the one that nobody tests: a partner in Germany sends a SKU with an umlaut. In a server, one request kills its worker thread. In a batch job, one line stops the whole import, and the error says nothing about which line.

  1. WorseThree indexes, each a panic on bad input.

    pub fn parse_line(line: &str) -> Line<'_> {
    let fields: Vec<&str> = line.split(',').collect();
    Line {
    id: fields[0],
    category: &fields[1][..3],
    quantity: fields[2],
    }
    }
  2. BetterA slice pattern checks the field count, and get(..3) returns None instead of a panic.

    #[derive(Debug, PartialEq, Eq)]
    pub enum LineError {
    FieldCount(usize),
    ShortSku,
    }
    pub fn parse_line(line: &str) -> Result<Line<'_>, LineError> {
    let fields: Vec<&str> = line.split(',').collect();
    let [id, sku, quantity] = fields[..] else {
    return Err(LineError::FieldCount(fields.len()));
    };
    let category = sku.get(..3).ok_or(LineError::ShortSku)?; // None if too short or not on a char boundary
    Ok(Line {
    id,
    category,
    quantity,
    })
    }

The tools that replace indexing:

  • Slice patterns: let [id, sku, quantity] = fields[..] else { ... } checks the length and names the fields in one step.
  • get: v.get(i) and s.get(a..b) return Option. None for out of range, and for a string range that cuts a character.
  • Methods that cannot fail: split_once, strip_prefix, first(), last(), chars().take(n), and iterators instead of index loops.
  • When the index is correct by construction, and a wrong index would be a bug in the program, not bad input:
const MONTHS: [&str; 12] = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
];
/// `month` comes from `chrono` or another type that guarantees 1 to 12.
/// A wrong value is a bug in this program, not bad input, so a panic is correct.
pub fn month_name(month: u32) -> &'static str {
MONTHS[month as usize - 1]
}
  • Right after a check: if v.len() >= 3 { &v[..3] }. A slice pattern or get is still clearer.
  • Indexes from the same collection: v.iter().position(...) returns a valid index for v, as long as v does not change in between.
  • Byte slices on ASCII-only data that you have validated, such as a fixed-format code that you checked with is_ascii().
  • Clippy indexing_slicing (restriction group, off by default) reports every index and slice that can panic. It is noisy in normal code, so many teams enable it only for parsing modules: #![warn(clippy::indexing_slicing)] at the top of the module.
  • Clippy string_slice (restriction group, off by default) reports slicing of strings.
  • In a review, trace the data: if an index or a range comes from input (a request, a file, a split of a line), ask what happens when it is out of range.
  • Test with short lines, empty fields, and non-ASCII text, not only with the sample from the prompt.
parse_line must not panic on any input. Replace fields[0], fields[1] and
fields[2] with a slice pattern (let [id, sku, quantity] = fields[..] else),
and replace &sku[..3] with sku.get(..3). Return a LineError for a wrong field
count and for a short SKU. Add tests for "7,BK-1234", "7,BK,2" and "7,BKü-1,2".

Related: S02: unwrap() / expect() in non-test code and S36: panic! used for normal errors.