fix(sheet): reject row/column ranges with trailing characters up front

parseRowColRange gates --styles row_sizes/col_sizes before the document is
created. The row branch parsed with fmt.Sscanf(a, "%d", &r1), which consumes
only the leading digits and does not require the whole token, so "1x:3" was
silently accepted as row 1 and "2foo" as row 2. Such input passed pre-flight,
then update_dimension was sent to the wrong row after the document and data had
already been created — an unrollbackable wrong edit, the opposite of the gate's
purpose. The row branch now parses with strconv.Atoi, which requires the entire
token to be a valid integer.

The column branch had the same class of hole via a different path: parseA1Cell
appends "1" to the column token, so "A5" became "A51" and was accepted as
column A. A new isAllLetters pre-check requires the column token to be non-empty
and letters-only before parsing; genuine multi-letter columns like "AX" still
pass. With that guarantee the subsequent parseA1Cell can no longer fail, so its
now-dead error branch is removed.

Adds trailing-character rejection cases to TestParseRowColRange: "1x:3",
"2foo", "1 2:3" (rows), "A5:C", "A1", ":C" (columns), plus "AX:C" to confirm
multi-letter columns remain valid. Changed-code coverage stays at 100%.
This commit is contained in:
huwenjiao.hwj committed 2026-08-07 20:26:12 +08:00
1 parent ecaa375be8
commit eb73b944a1
2 files changed
+40 -8

No files matched your search

+29 -8
View File
@@ -380,6 +380,21 @@ func cellStyleItemToSpec(item map[string]any) (*styleSpec, string, error) {
return spec, rangeAddr, nil
}
// isAllLetters 判断字符串是否非空且只含英文字母(大小写皆可),用于列范围
// 预检:拒绝 "A5" / "Ax" 这类带数字或尾随字符、会被 parseA1Cell 误判的列名。
func isAllLetters(s string) bool {
if s == "" {
return false
}
for i := 0; i < len(s); i++ {
c := s[i]
if (c < 'A' || c > 'Z') && (c < 'a' || c > 'z') {
return false
}
}
return true
}
// parseRowColRange 解析行/列范围:"1:3"→(start "1", len 3);"A:C"→(start "A", len 3);"1"/"A"→len 1。
func parseRowColRange(addr string, isRow bool) (start string, length int, err error) {
addr = strings.TrimSpace(addr)
@@ -396,11 +411,13 @@ func parseRowColRange(addr string, isRow bool) (start string, length int, err er
b = strings.TrimSpace(parts[1])
}
if isRow {
var r1, r2 int
if _, e := fmt.Sscanf(a, "%d", &r1); e != nil || r1 < 1 {
return "", 0, fmt.Errorf("行范围非法: %s(应形如 \"1:3\")", addr)
}
if _, e := fmt.Sscanf(b, "%d", &r2); e != nil || r2 < 1 {
// strconv.Atoi 要求整个字符串都是合法整数,拒绝 "1x" / "2foo" 这类带尾随
// 字符的输入。fmt.Sscanf("%d") 只消费前缀数字,会把 "1x" 静默当成第 1 行 ——
// 这是建文档前的校验关口,放过后 update_dimension 会改到错误的行,且此时
// 文档与数据已创建、无法原子回滚。
r1, e1 := strconv.Atoi(a)
r2, e2 := strconv.Atoi(b)
if e1 != nil || e2 != nil || r1 < 1 || r2 < 1 {
return "", 0, fmt.Errorf("行范围非法: %s(应形如 \"1:3\")", addr)
}
if r2 < r1 {
@@ -408,11 +425,15 @@ func parseRowColRange(addr string, isRow bool) (start string, length int, err er
}
return fmt.Sprintf("%d", r1), r2 - r1 + 1, nil
}
c1, _, e1 := parseA1Cell(strings.ToUpper(a) + "1")
c2, _, e2 := parseA1Cell(strings.ToUpper(b) + "1")
if e1 != nil || e2 != nil {
// 列同理必须是纯字母:parseA1Cell 会给列名补 "1" 再解析,"A5" 会变成 "A51"
// 被当成 A 列静默放过。先要求 a/b 只含字母,杜绝带数字/尾随字符的输入通过预检;
// 纯多字母列名(如 "AX")是合法的,正常通过。
if !isAllLetters(a) || !isAllLetters(b) {
return "", 0, fmt.Errorf("列范围非法: %s(应形如 \"A:C\")", addr)
}
// isAllLetters 已保证 a/b 为非空纯字母,补 "1" 后 parseA1Cell 必然成功,无需再判错。
c1, _, _ := parseA1Cell(strings.ToUpper(a) + "1")
c2, _, _ := parseA1Cell(strings.ToUpper(b) + "1")
// 反序范围(如 "C:A")必须同时交换起始列,否则会静默改错目标:
// 只交换用于算长度的索引会得到 startIndex="C"/length=3,改到 C/D/E 而非 A/B/C。
// 行分支同样在交换后返回较小的 r1。
@@ -812,6 +812,17 @@ func TestParseRowColRange(t *testing.T) {
{"0:2", true, "", 0, true}, // 行号必须 >= 1
{"1:0", true, "", 0, true},
{"1:2", false, "", 0, true}, // 列范围收到数字
// 带尾随字符的行范围必须整体拒绝:Sscanf("%d") 只消费前缀数字,会把
// "1x"/"2foo" 静默当成第 1/2 行,放过后 update_dimension 改到错误行。
{"1x:3", true, "", 0, true},
{"2foo", true, "", 0, true},
{"1 2:3", true, "", 0, true}, // 内部空格也非法
// 列范围拒绝带数字/尾随字符:否则 "A5" 会被 parseA1Cell(补 "1" 后)
// 当成 A 列静默放过。纯多字母列名(如 "AX")是合法的,不在此列。
{"A5:C", false, "", 0, true},
{"A1", false, "", 0, true},
{":C", false, "", 0, true}, // 空列 token 也要拒绝
{"AX:C", false, "C", 48, false}, // 多字母列合法:C..AX 共 48 列
}
for _, tc := range cases {
start, length, err := parseRowColRange(tc.addr, tc.isRow)