Field Note

What abs() forgets when its input is i32::MIN

A brass balance scale on a walnut desk. Eight small weights stacked on the left pan, seven on the right. The beam tilts slightly to the left, off-balance.

A SQLite-compatible engine, a one-line PRAGMA, and a debug build is all you need to reproduce the bug:

PRAGMA cache_size = -2147483648;
CREATE TABLE t(x);
INSERT INTO t VALUES (2), (1);
SELECT x FROM t ORDER BY x;

In turso, that ORDER BY panicked. The panic message read attempt to negate with overflow. The PRAGMA and the table creation completed without complaint. Only the sort path tripped.

The fix was four characters: abs() became unsigned_abs(), and the multiply that followed picked up a saturating qualifier. The reason the four characters mattered requires reading what Rust's i32::abs actually promises.

The fault site

The panic resolved to op_sorter_open in core/vdbe/execute.rs around line 6320. The relevant lines were:

let cache_size = state.get_cache_size();
let threshold = if cache_size < 0 {
    (cache_size.abs() * 1024) as usize
} else {
    // pages-mode branch, not relevant here
    ...
};

Turso uses the SQLite convention where a negative cache_size is interpreted as a kilobyte budget rather than a page count, so the sort threshold is the magnitude of the value times one thousand and twenty-four. The .abs() takes the magnitude. With cache_size = -2147483648, which is i32::MIN, the magnitude does not fit.

Why i32::MIN.abs() panics

i32 is a signed 32-bit integer. Its range is -2147483648 to 2147483647: -2^31 to 2^31 - 1. The asymmetry is the whole story. The range has one more negative value than positive value because zero takes a slot on the non-negative side.

So |i32::MIN| is 2^31, which is one larger than i32::MAX. It does not fit. Rust's i32::abs documentation says exactly this: "Overflow happens when self == MIN." In debug builds the overflow panics; in release builds it wraps and the function quietly returns i32::MIN again.

Either outcome is wrong for a cache-size threshold. The panic is loud; the wrap is silent and produces a strongly-negative usize through the subsequent cast. The release-build behavior would have been worse, not better.

The fix

Rust's standard library exposes i32::unsigned_abs for exactly this case. It returns a u32, which has enough room to hold 2^31. There is no signed range left to overflow.

let cache_size = state.get_cache_size();
let threshold = if cache_size < 0 {
    (cache_size.unsigned_abs() as usize).saturating_mul(1024)
} else {
    ...
};

Two changes. unsigned_abs() handles the magnitude. saturating_mul(1024) handles the second overflow risk: on 32-bit targets, usize is also 32 bits, and 2^31 * 1024 overflows that too. Saturation pins the threshold at usize::MAX when the math would otherwise wrap. The bug class is bounded.

Where the negative survives

The honest question is why the negative reaches the sorter at all. PRAGMA values are validated when written. update_cache_size in core/translate/pragma.rs already guards the i64 branch with checked_abs, landed in turso's PR #5258. The guard works for the type it sees.

The trap is two lines below the guard. After validation, the value is cast to i32 with a plain as conversion for storage. as-casts in Rust are saturating for the float case and truncating for the integer case, but neither matters here because -2147483648 already fits in i32. The cast preserves the value exactly. It is i32::MIN at the input, i32::MIN at the output, and the storage holds it.

So the validation guards the wrong type. The arithmetic that overflows is downstream of the storage, on the i32 that survives the cast intact. The right place to fix the bug is at the arithmetic, not at the input gate, because the gate already cleared a value that becomes dangerous only after the storage type narrows.

The general shape

Three ingredients produced this bug. They will produce it again.

One. A user-controllable signed integer reaches arithmetic that assumes a non-negative magnitude.

Two. The arithmetic uses .abs() rather than .unsigned_abs() or .checked_abs(). The first overflows on MIN; the latter two do not.

Three. The build profile that the user runs is either debug (panic) or release (silent wrap into a large unsigned cast). Both are wrong; neither is benign.

Greppable signature in Rust code: any x.abs() where x is a signed integer type that came from user input or external storage. The fix is mechanical. Replace abs() with unsigned_abs() if you want the magnitude, or with checked_abs() if you want a fallible result. Add a saturating qualifier to any subsequent multiply where the magnitude could push the product past its target type.

Takeaway

When a signed integer with attacker-controlled or pragma-controlled range reaches .abs(), the function will silently lie or loudly panic on exactly one input. There is no third path. The standard library gives you unsigned_abs() precisely so that the magnitude path can be total. Take it.

And when a validation guard runs on a wider type before the value narrows for storage, the guard does not transfer. The bug class is downstream of the cast.

The trace and the patch live at tursodatabase/turso#7151 against issue #7150. The related i64 guard is #5258.

References: Rust's i32::abs and i32::unsigned_abs documentation, the saturating_mul docs.