Fix str.expandtabs aborting on a tab size of zero (#8562)

`"a\tb".expandtabs(0)` panicked with a capacity overflow. CPython returns
`'ab'`: with no width to advance to, the tabs come out and nothing else
moves. `expandtabs(-1)` is the same call, since `ExpandTabsArgs::tabsize`
sends every negative value to 0.

`expandtabs` keeps the tab stop in `tab_size` and the current column in
`col_count`, and on a tab it does `tab_size - col_count`. With a tab size of
zero both start at 0, the first character makes `col_count` 1 while
`tab_size` stays 0, and the subtraction underflows. The run of spaces asked
for next is `usize::MAX`, and the allocation aborts the process. A tab has to
follow something on the line to reach it: `"\ta".expandtabs(0)` subtracts 0
from 0 and comes out right by accident.

`BytesInner::expandtabs` already returns early for this and filters the tabs
out. The string version now does the same.

Assisted-by: Claude Code:claude-opus-5
This commit is contained in:
Luan Taraschi authored and GitHub committed 2026-08-21 20:29:22 +09:00
1 parent c4549e1985
commit cdb8a8dd83
2 files changed
+61

No files matched your search

+33
View File
@@ -743,6 +743,15 @@ pub mod levenshtein {
/// Replace all tabs in a string with spaces, using the given tab size.
#[must_use]
pub fn expandtabs(input: &str, tab_size: usize) -> String {
// A tab size of zero, which is also where a negative one lands, leaves no
// column for a tab to advance to: the tabs come out and nothing else moves.
// Going through the arithmetic anyway subtracts the current column from a
// tab stop of zero and underflows on the first tab, so the width asked for
// next is `usize::MAX`. The bytes version of this already returns here.
if tab_size == 0 {
return input.chars().filter(|ch| *ch != '\t').collect();
}
let tab_stop = tab_size;
let mut expanded_str = String::with_capacity(input.len());
let mut tab_size = tab_stop;
@@ -905,4 +914,28 @@ mod tests {
let s = "0😀😃😄😁😆😅😂🤣9";
assert_eq!(get_chars(s, 3..7), "😄😁😆😅");
}
#[test]
fn expandtabs_with_zero_tab_size_drops_tabs() {
// A tab that follows a character used to subtract that column from a
// tab stop of zero, so the width of the run of spaces came out as
// `usize::MAX` and the allocation aborted the process.
assert_eq!(expandtabs("a\tb", 0), "ab");
assert_eq!(expandtabs("ab\tcd\tef", 0), "abcdef");
assert_eq!(expandtabs("a\nb\tc", 0), "a\nbc");
assert_eq!(expandtabs("á\tb", 0), "áb");
assert_eq!(expandtabs("\ta", 0), "a");
assert_eq!(expandtabs("\t", 0), "");
assert_eq!(expandtabs("", 0), "");
assert_eq!(expandtabs("no tabs", 0), "no tabs");
}
#[test]
fn expandtabs_with_a_real_tab_size_is_unchanged() {
assert_eq!(expandtabs("a\tb", 8), "a b");
assert_eq!(expandtabs("a\tb", 1), "a b");
assert_eq!(expandtabs("abcd\te", 4), "abcd e");
assert_eq!(expandtabs("a\nb\tc", 4), "a\nb c");
assert_eq!(expandtabs("\ta", 4), " a");
}
}
+28
View File
@@ -951,3 +951,31 @@ def test_replace_empty_pattern():
test_replace_empty_pattern()
def test_expandtabs_zero_tabsize():
# With no width to advance to, the tabs come out and nothing else moves.
# A tab that followed a character used to ask for a run of usize::MAX
# spaces and take the interpreter down with it.
for tabsize in (0, -1, -8):
assert "a\tb".expandtabs(tabsize) == "ab"
assert "ab\tcd\tef".expandtabs(tabsize) == "abcdef"
assert "a\nb\tc".expandtabs(tabsize) == "a\nbc"
assert "a\r\nb\tc".expandtabs(tabsize) == "a\r\nbc"
assert "á\tb".expandtabs(tabsize) == "áb"
assert "😀\tb".expandtabs(tabsize) == "😀b"
assert "\ta".expandtabs(tabsize) == "a"
assert "\t".expandtabs(tabsize) == ""
assert "".expandtabs(tabsize) == ""
assert "no tabs".expandtabs(tabsize) == "no tabs"
assert b"a\tb".expandtabs(tabsize) == b"ab"
assert bytearray(b"a\tb").expandtabs(tabsize) == bytearray(b"ab")
# A tab size that is actually there keeps working.
assert "a\tb".expandtabs(8) == "a b"
assert "a\tb".expandtabs(1) == "a b"
assert "abcd\te".expandtabs(4) == "abcd e"
assert "a\nb\tc".expandtabs(4) == "a\nb c"
test_expandtabs_zero_tabsize()