Skip to content

Commit eed711b

Browse files
feat(sheets): guard +csv-put --csv against a path passed without @ (#1337)
+csv-put --csv data.csv (a forgotten @) was silently written as one-cell content, because any string parses as valid CSV — unlike malformed JSON it never errored, so the filename landed in the sheet instead of the file's contents. +csv-put's Validate now rejects a --csv value when it names a real file in the cwd subtree (guardCSVValueIsNotFilePath; fileIO.Stat, fail-open), hinting to use --csv @file or stdin (--csv -). Scoped to --csv only — no framework or other-flag change. Checking real existence (not name shape) lets inline content that merely ends in a filename pass through. Adds TestGuardCSVValueIsNotFilePath.
1 parent 4f4c0b5 commit eed711b

2 files changed

Lines changed: 94 additions & 1 deletion

File tree

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
2+
// SPDX-License-Identifier: MIT
3+
4+
package sheets
5+
6+
import (
7+
"os"
8+
"strings"
9+
"testing"
10+
11+
"github.com/larksuite/cli/internal/cmdutil"
12+
_ "github.com/larksuite/cli/internal/vfs/localfileio"
13+
"github.com/larksuite/cli/shortcuts/common"
14+
"github.com/spf13/cobra"
15+
)
16+
17+
func newCSVGuardRuntime(csvVal string) *common.RuntimeContext {
18+
cmd := &cobra.Command{Use: "test"}
19+
cmd.Flags().String("csv", "", "")
20+
cmd.ParseFlags(nil)
21+
cmd.Flags().Set("csv", csvVal)
22+
return &common.RuntimeContext{Cmd: cmd}
23+
}
24+
25+
// TestGuardCSVValueIsNotFilePath verifies the guard flags a bare --csv value
26+
// only when it names a real file (a forgotten @), while leaving genuine inline
27+
// content alone — including the case the old name-shape heuristic got wrong:
28+
// prose that merely ends in or mentions a filename.
29+
func TestGuardCSVValueIsNotFilePath(t *testing.T) {
30+
dir := t.TempDir()
31+
cmdutil.TestChdir(t, dir)
32+
if err := os.WriteFile("data.csv", []byte("a,b\n1,2\n"), 0644); err != nil {
33+
t.Fatal(err)
34+
}
35+
36+
// Bare value naming an existing file → guarded with a fix-it hint.
37+
err := guardCSVValueIsNotFilePath(newCSVGuardRuntime("data.csv"))
38+
if err == nil {
39+
t.Fatal("expected guard error when --csv names an existing file")
40+
}
41+
if !strings.Contains(err.Error(), "existing file") || !strings.Contains(err.Error(), "@data.csv") {
42+
t.Errorf("error should flag the file and suggest @data.csv, got: %v", err)
43+
}
44+
45+
// Content that is not a real file must pass through unchanged.
46+
for _, v := range []string{
47+
"改完记得更新config.json", // prose ending in a filename — not a real file
48+
"remember to update data.csv", // mentions the real file but isn't its name
49+
"a,b\n1,2", // multi-cell CSV
50+
"hello world",
51+
"nope.csv", // path-shaped but no such file
52+
"",
53+
} {
54+
if err := guardCSVValueIsNotFilePath(newCSVGuardRuntime(v)); err != nil {
55+
t.Errorf("content %q must pass through, got: %v", v, err)
56+
}
57+
}
58+
}

shortcuts/sheets/lark_sheet_write_cells.go

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -219,7 +219,12 @@ var CsvPut = common.Shortcut{
219219
}
220220
cmd.MarkFlagsOneRequired("start-cell", "range")
221221
},
222-
Validate: validateViaInput(csvPutInput),
222+
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
223+
if err := guardCSVValueIsNotFilePath(runtime); err != nil {
224+
return err
225+
}
226+
return validateViaInput(csvPutInput)(ctx, runtime)
227+
},
223228
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
224229
token, _ := resolveSpreadsheetToken(runtime)
225230
sheetID, sheetName, _ := resolveSheetSelector(runtime)
@@ -295,6 +300,36 @@ func csvPutWriteRangeFromInput(input map[string]interface{}) (string, bool) {
295300
return fmt.Sprintf("%s:%s%d", anchor, endCol, endRow), true
296301
}
297302

303+
// guardCSVValueIsNotFilePath catches the common slip of passing a CSV file path
304+
// to --csv without the "@" that reads it (e.g. `--csv data.csv` instead of
305+
// `--csv @data.csv`). Because any string is a valid one-cell CSV, the mistake
306+
// would otherwise be written silently as the literal text "data.csv". It runs
307+
// in +csv-put's Validate, after resolveInputFlags — so an @file / stdin value is
308+
// already its contents (a real CSV blob, never a path) and only a bare value
309+
// reaches here unchanged. It flags the value only when it actually names an
310+
// existing file in the cwd subtree; checking real existence (not name shape)
311+
// means inline content that merely ends in a filename ("see config.json") is
312+
// never misjudged. Fails open: any Stat error or a directory leaves the value
313+
// untouched. Scoped to --csv only — no other flag is affected.
314+
func guardCSVValueIsNotFilePath(runtime *common.RuntimeContext) error {
315+
raw := strings.TrimSpace(runtime.Str("csv"))
316+
if raw == "" {
317+
return nil
318+
}
319+
fio := runtime.FileIO()
320+
if fio == nil {
321+
return nil
322+
}
323+
info, err := fio.Stat(raw)
324+
if err != nil || info == nil || info.IsDir() {
325+
return nil //nolint:nilerr // fail-open: a missing/unreadable path is treated as inline content, not a forgotten @
326+
}
327+
return common.FlagErrorf(
328+
"--csv value %q is an existing file, not inline CSV; to read it use --csv @%s, or pass the literal text via stdin (--csv -)",
329+
raw, raw,
330+
)
331+
}
332+
298333
func csvPutInput(runtime flagView, token, sheetID, sheetName string) (map[string]interface{}, error) {
299334
if err := requireSheetSelector(sheetID, sheetName); err != nil {
300335
return nil, err

0 commit comments

Comments
 (0)