From 410844f68f7a57963e8dd2d5256f8d775bb020f9 Mon Sep 17 00:00:00 2001 From: Luan Taraschi <130802253+luantaraschi@users.noreply.github.com> Date: Thu, 20 Aug 2026 19:40:38 -0300 Subject: [PATCH] Fix str.expandtabs aborting on a tab size of zero `"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 --- crates/common/src/str.rs | 33 +++++++++++++++++++++++++++++ extra_tests/snippets/builtin_str.py | 28 ++++++++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/crates/common/src/str.rs b/crates/common/src/str.rs index c649c057de6..3e972dc7bbe 100644 --- a/crates/common/src/str.rs +++ b/crates/common/src/str.rs @@ -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"); + } } diff --git a/extra_tests/snippets/builtin_str.py b/extra_tests/snippets/builtin_str.py index 859e8b7a7a8..ccd2a03527c 100644 --- a/extra_tests/snippets/builtin_str.py +++ b/extra_tests/snippets/builtin_str.py @@ -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()