Mehdi Akiki
Rust Failure Atlas / Runtime, memory, and library APIs

RFA-223 · Case file with fixtures · Case 195 of 694 · Runtime evidence

Rust checked_shl Checks the Shift Distance, Not Lost High Bits

checked_shl returns None only when the shift count is at least the integer width. Detecting discarded high bits requires widening, a range check, or a domain-specific precondition.

Reviewed
Rust
Rust 1.98.1, edition 2024
Targets
all targets
Profiles
dev, release, test

Direct answer

What this Rust failure means

Why it happens
checked_shl validates whether the shift distance is below the integer width; it does not report non-zero high bits discarded by an otherwise valid shift.
First discriminating check
Test a valid distance that loses a high bit and a distance equal to the type width, then widen the value before shifting.

The checked_ prefix led me to expect u8::MAX.checked_shl(1) to return None. Mathematically, 255 shifted left once is 510, which cannot fit in eight bits. Rust returns Some(254).

The failing program isolates this expectation. The operation is checked, but it checks a different kind of invalid input.

The checked condition is the right-hand operand

u8::checked_shl returns None when the shift amount is greater than or equal to the number of bits in the type. For a u8, shifting by eight is invalid under this contract. Shifting by one is a valid shift distance.

Bits leaving the left side are discarded. The low eight bits of 510 are 254, so that is the returned value.

This differs from checked_mul(2), which checks whether the arithmetic product fits. A left shift often corresponds to multiplication by a power of two, but the shift API describes bit movement and shift-count validity.

“Overflow” names more than one event

There are at least two possible failures:

  1. the shift count is outside the type width;
  2. significant bits are lost even though the shift count is valid.

checked_shl detects the first. My application cared about the second.

overflowing_shl reinforces the distinction: its boolean reports whether the shift amount overflowed the width, not whether non-zero high bits were discarded. Choosing another shift method without reading its boolean contract would reproduce the mistake.

I widen when I mean mathematical multiplication

The repaired program shifts after converting the value to u16, then uses TryFrom to test whether the result fits back into u8.

This makes both widths visible:

let wide = u16::from(value) << amount;
let narrow = u8::try_from(wide)?;

For generic integer code, I cannot always assume that doubling the width is available or enough. Another valid check is that value <= MAX >> amount, after first validating the amount. If the domain operation is multiplication, checked_mul communicates the intention more directly.

Shifts in masks may want the bit contract

Discarding high bits is not always an error. Hash functions, packed protocols, circular transformations, and low-level masks can deliberately keep a fixed-width result. In those cases, checked_shl can be exactly right when the untrusted part is the shift count.

I avoid adding a mathematical-overflow check by habit. It can reject valid bit manipulation and imply a guarantee the algorithm does not need.

The name of the value helps: bit_mask suggests fixed-width semantics, while item_count_times_two suggests arithmetic range semantics.

Debug and release do not rescue the assumption

Ordinary arithmetic overflow can behave differently depending on overflow checks. An explicit method exists to choose behaviour consistently. Here, checked_shl itself consistently returns Some(254) for a shift of one. This is not a release-mode surprise.

Tests should not rely on a debug panic from the << operator to detect lost bits. The explicit contract must match the invariant in every profile.

Signed values add another semantic question

Left-shifting a signed integer moves its two's-complement bit representation. If the code intends multiplication over signed mathematical integers, negative values and the sign bit deserve explicit bounds. Widening before the operation and converting back is easier to audit than reasoning from a truncated result.

For serialization, I usually work with unsigned types and document field widths. For domain quantities, I prefer checked arithmetic on the domain type.

My boundary table covers both failure axes

The regression checks a small value that fits, a maximum value that loses a bit, a shift of width minus one, a shift equal to the width, and a shift larger than the width. The repaired assertion proves that widening detects 510 as outside u8.

This table prevents one successful checked_shl call from being misread as a general no-overflow proof.

When the shift amount comes from input, I keep rejection of an invalid distance separate from rejection of an unrepresentable mathematical result. Distinct error variants make logs and caller recovery much less ambiguous.

The core principle is that “checked” is incomplete without the checked condition. Rust checks the shift distance here. If my invariant is that no significant bit disappears, I must express and test that separate invariant before accepting the fixed-width result.