diff --git a/shortcuts/sheets/csv_put_guard_test.go b/shortcuts/sheets/csv_put_guard_test.go new file mode 100644 index 000000000..5191e45ee --- /dev/null +++ b/shortcuts/sheets/csv_put_guard_test.go @@ -0,0 +1,58 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package sheets + +import ( + "os" + "strings" + "testing" + + "github.com/larksuite/cli/internal/cmdutil" + _ "github.com/larksuite/cli/internal/vfs/localfileio" + "github.com/larksuite/cli/shortcuts/common" + "github.com/spf13/cobra" +) + +func newCSVGuardRuntime(csvVal string) *common.RuntimeContext { + cmd := &cobra.Command{Use: "test"} + cmd.Flags().String("csv", "", "") + cmd.ParseFlags(nil) + cmd.Flags().Set("csv", csvVal) + return &common.RuntimeContext{Cmd: cmd} +} + +// TestGuardCSVValueIsNotFilePath verifies the guard flags a bare --csv value +// only when it names a real file (a forgotten @), while leaving genuine inline +// content alone — including the case the old name-shape heuristic got wrong: +// prose that merely ends in or mentions a filename. +func TestGuardCSVValueIsNotFilePath(t *testing.T) { + dir := t.TempDir() + cmdutil.TestChdir(t, dir) + if err := os.WriteFile("data.csv", []byte("a,b\n1,2\n"), 0644); err != nil { + t.Fatal(err) + } + + // Bare value naming an existing file → guarded with a fix-it hint. + err := guardCSVValueIsNotFilePath(newCSVGuardRuntime("data.csv")) + if err == nil { + t.Fatal("expected guard error when --csv names an existing file") + } + if !strings.Contains(err.Error(), "existing file") || !strings.Contains(err.Error(), "@data.csv") { + t.Errorf("error should flag the file and suggest @data.csv, got: %v", err) + } + + // Content that is not a real file must pass through unchanged. + for _, v := range []string{ + "改完记得更新config.json", // prose ending in a filename — not a real file + "remember to update data.csv", // mentions the real file but isn't its name + "a,b\n1,2", // multi-cell CSV + "hello world", + "nope.csv", // path-shaped but no such file + "", + } { + if err := guardCSVValueIsNotFilePath(newCSVGuardRuntime(v)); err != nil { + t.Errorf("content %q must pass through, got: %v", v, err) + } + } +} diff --git a/shortcuts/sheets/lark_sheet_write_cells.go b/shortcuts/sheets/lark_sheet_write_cells.go index 07768614f..9cb51fbae 100644 --- a/shortcuts/sheets/lark_sheet_write_cells.go +++ b/shortcuts/sheets/lark_sheet_write_cells.go @@ -219,7 +219,12 @@ var CsvPut = common.Shortcut{ } cmd.MarkFlagsOneRequired("start-cell", "range") }, - Validate: validateViaInput(csvPutInput), + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if err := guardCSVValueIsNotFilePath(runtime); err != nil { + return err + } + return validateViaInput(csvPutInput)(ctx, runtime) + }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { token, _ := resolveSpreadsheetToken(runtime) sheetID, sheetName, _ := resolveSheetSelector(runtime) @@ -295,6 +300,36 @@ func csvPutWriteRangeFromInput(input map[string]interface{}) (string, bool) { return fmt.Sprintf("%s:%s%d", anchor, endCol, endRow), true } +// guardCSVValueIsNotFilePath catches the common slip of passing a CSV file path +// to --csv without the "@" that reads it (e.g. `--csv data.csv` instead of +// `--csv @data.csv`). Because any string is a valid one-cell CSV, the mistake +// would otherwise be written silently as the literal text "data.csv". It runs +// in +csv-put's Validate, after resolveInputFlags — so an @file / stdin value is +// already its contents (a real CSV blob, never a path) and only a bare value +// reaches here unchanged. It flags the value only when it actually names an +// existing file in the cwd subtree; checking real existence (not name shape) +// means inline content that merely ends in a filename ("see config.json") is +// never misjudged. Fails open: any Stat error or a directory leaves the value +// untouched. Scoped to --csv only — no other flag is affected. +func guardCSVValueIsNotFilePath(runtime *common.RuntimeContext) error { + raw := strings.TrimSpace(runtime.Str("csv")) + if raw == "" { + return nil + } + fio := runtime.FileIO() + if fio == nil { + return nil + } + info, err := fio.Stat(raw) + if err != nil || info == nil || info.IsDir() { + return nil //nolint:nilerr // fail-open: a missing/unreadable path is treated as inline content, not a forgotten @ + } + return common.FlagErrorf( + "--csv value %q is an existing file, not inline CSV; to read it use --csv @%s, or pass the literal text via stdin (--csv -)", + raw, raw, + ) +} + func csvPutInput(runtime flagView, token, sheetID, sheetName string) (map[string]interface{}, error) { if err := requireSheetSelector(sheetID, sheetName); err != nil { return nil, err