From 7f526bf9fbada878bc26a92aa396c30844c1e332 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=A2=81=E7=A1=95?= Date: Wed, 29 Apr 2026 18:03:13 +0800 Subject: [PATCH 1/2] feat(cmdutil): support @file for --params/--data (issue #705) Inline JSON values for --params/--data are mangled by Windows PowerShell 5's CommandLineToArgvW. Stdin (-) was the only escape hatch but supports just one flag at a time. Extend ResolveInput to accept @ (read JSON from a file) and @@... (escape for a literal @-prefixed value), mirroring the shortcuts framework's resolveInputFlags semantics. With this, both --params and --data can be sourced from files in the same call, sidestepping shell quoting on every platform. - internal/cmdutil/resolve.go: add @path / @@ handling, trim file content like stdin does, error on empty path or empty file - internal/cmdutil/resolve_test.go: cover file read, whitespace trim, missing file, empty path, empty content, @@ escape, plus ParseJSONMap / ParseOptionalBody integration through @file - cmd/api/api.go, cmd/service/service.go: update --params/--data help text to mention @file Change-Id: I366aa0f5783fbec6f05403f7f542505098a98c82 --- cmd/api/api.go | 4 +- cmd/service/service.go | 4 +- internal/cmdutil/resolve.go | 35 +++++++++-- internal/cmdutil/resolve_test.go | 105 +++++++++++++++++++++++++++++-- 4 files changed, 134 insertions(+), 14 deletions(-) diff --git a/cmd/api/api.go b/cmd/api/api.go index 2964d80af..a8de85ce9 100644 --- a/cmd/api/api.go +++ b/cmd/api/api.go @@ -81,8 +81,8 @@ func NewCmdApiWithContext(ctx context.Context, f *cmdutil.Factory, runF func(*AP }, } - cmd.Flags().StringVar(&opts.Params, "params", "", "query parameters JSON (supports - for stdin)") - cmd.Flags().StringVar(&opts.Data, "data", "", "request body JSON (supports - for stdin)") + cmd.Flags().StringVar(&opts.Params, "params", "", "query parameters JSON (supports - for stdin, @file for file input)") + cmd.Flags().StringVar(&opts.Data, "data", "", "request body JSON (supports - for stdin, @file for file input)") cmdutil.AddAPIIdentityFlag(ctx, cmd, f, &asStr) cmd.Flags().StringVarP(&opts.Output, "output", "o", "", "output file path for binary responses") cmd.Flags().BoolVar(&opts.PageAll, "page-all", false, "automatically paginate through all pages") diff --git a/cmd/service/service.go b/cmd/service/service.go index 56392c48b..f7bd89cd8 100644 --- a/cmd/service/service.go +++ b/cmd/service/service.go @@ -167,10 +167,10 @@ func NewCmdServiceMethodWithContext(ctx context.Context, f *cmdutil.Factory, spe }, } - cmd.Flags().StringVar(&opts.Params, "params", "", "URL/query parameters JSON (supports - for stdin)") + cmd.Flags().StringVar(&opts.Params, "params", "", "URL/query parameters JSON (supports - for stdin, @file for file input)") switch httpMethod { case "POST", "PUT", "PATCH", "DELETE": - cmd.Flags().StringVar(&opts.Data, "data", "", "request body JSON (supports - for stdin)") + cmd.Flags().StringVar(&opts.Data, "data", "", "request body JSON (supports - for stdin, @file for file input)") } cmdutil.AddAPIIdentityFlag(ctx, cmd, f, &asStr) cmd.Flags().StringVarP(&opts.Output, "output", "o", "", "output file path for binary responses") diff --git a/internal/cmdutil/resolve.go b/internal/cmdutil/resolve.go index a40475680..44d8d9943 100644 --- a/internal/cmdutil/resolve.go +++ b/internal/cmdutil/resolve.go @@ -6,16 +6,19 @@ package cmdutil import ( "fmt" "io" + "os" "strings" ) // ResolveInput resolves special input conventions for a raw flag value: -// - "-" → read all bytes from stdin -// - "'...'" → strip surrounding single quotes (Windows cmd.exe compatibility) -// - other → return as-is +// - "-" → read all bytes from stdin +// - "@" → read all bytes from the file at +// - "@@..." → strip leading @ (escape for a literal @-prefixed value) +// - "'...'" → strip surrounding single quotes (Windows cmd.exe compatibility) +// - other → return as-is // -// This allows callers to bypass shell quoting issues (especially on Windows -// PowerShell) by piping JSON via stdin instead of command-line arguments. +// Allows callers to bypass shell quoting issues (especially Windows PowerShell 5) +// by reading JSON from a file (@path) or piping via stdin (-). func ResolveInput(raw string, stdin io.Reader) (string, error) { if raw == "" { return "", nil @@ -37,6 +40,28 @@ func ResolveInput(raw string, stdin io.Reader) (string, error) { return s, nil } + // escape: @@... → literal @... (no file read) + if strings.HasPrefix(raw, "@@") { + return raw[1:], nil + } + + // file: @path + if strings.HasPrefix(raw, "@") { + path := strings.TrimSpace(raw[1:]) + if path == "" { + return "", fmt.Errorf("file path cannot be empty after @") + } + data, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("cannot read file %q: %w", path, err) + } + s := strings.TrimSpace(string(data)) + if s == "" { + return "", fmt.Errorf("file %q is empty", path) + } + return s, nil + } + // strip surrounding single quotes (Windows cmd.exe passes them literally) if len(raw) >= 2 && raw[0] == '\'' && raw[len(raw)-1] == '\'' { raw = raw[1 : len(raw)-1] diff --git a/internal/cmdutil/resolve_test.go b/internal/cmdutil/resolve_test.go index c83f188cf..7fb85d590 100644 --- a/internal/cmdutil/resolve_test.go +++ b/internal/cmdutil/resolve_test.go @@ -5,6 +5,8 @@ package cmdutil import ( "fmt" + "os" + "path/filepath" "strings" "testing" ) @@ -108,14 +110,69 @@ func TestResolveInput_PlainValue(t *testing.T) { } } -func TestResolveInput_AtPrefixPassedThrough(t *testing.T) { - // Without @file support, @-prefixed values are passed as-is - got, err := ResolveInput("@something", nil) +func TestResolveInput_AtFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "params.json") + if err := os.WriteFile(path, []byte(`{"folder_token":"abc123"}`), 0o600); err != nil { + t.Fatal(err) + } + got, err := ResolveInput("@"+path, nil) if err != nil { t.Fatalf("unexpected error: %v", err) } - if got != "@something" { - t.Errorf("got %q, want %q", got, "@something") + if got != `{"folder_token":"abc123"}` { + t.Errorf("got %q", got) + } +} + +func TestResolveInput_AtFile_TrimsWhitespace(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "p.json") + if err := os.WriteFile(path, []byte("\n {\"k\":\"v\"}\n"), 0o600); err != nil { + t.Fatal(err) + } + got, err := ResolveInput("@"+path, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != `{"k":"v"}` { + t.Errorf("got %q", got) + } +} + +func TestResolveInput_AtFile_NotFound(t *testing.T) { + _, err := ResolveInput("@/no/such/file.json", nil) + if err == nil || !strings.Contains(err.Error(), "cannot read file") { + t.Errorf("expected read error, got: %v", err) + } +} + +func TestResolveInput_AtFile_EmptyPath(t *testing.T) { + _, err := ResolveInput("@", nil) + if err == nil || !strings.Contains(err.Error(), "file path cannot be empty") { + t.Errorf("expected empty-path error, got: %v", err) + } +} + +func TestResolveInput_AtFile_EmptyContent(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "empty.json") + if err := os.WriteFile(path, []byte(" \n"), 0o600); err != nil { + t.Fatal(err) + } + _, err := ResolveInput("@"+path, nil) + if err == nil || !strings.Contains(err.Error(), "is empty") { + t.Errorf("expected empty-file error, got: %v", err) + } +} + +func TestResolveInput_DoubleAtEscape(t *testing.T) { + got, err := ResolveInput("@@literal", nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "@literal" { + t.Errorf("got %q, want %q", got, "@literal") } } @@ -131,6 +188,44 @@ func TestParseJSONMap_WithStdin(t *testing.T) { } } +// Integration: @file flows through ParseJSONMap correctly. +func TestParseJSONMap_WithAtFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "params.json") + if err := os.WriteFile(path, []byte(`{"folder_token":"abc123","type":"folder"}`), 0o600); err != nil { + t.Fatal(err) + } + got, err := ParseJSONMap("@"+path, "--params", nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(got) != 2 { + t.Errorf("got %d keys, want 2", len(got)) + } + if got["folder_token"] != "abc123" { + t.Errorf("got %v, want folder_token=abc123", got) + } +} + +func TestParseOptionalBody_WithAtFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "data.json") + if err := os.WriteFile(path, []byte(`{"text":"hello"}`), 0o600); err != nil { + t.Fatal(err) + } + got, err := ParseOptionalBody("POST", "@"+path, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + m, ok := got.(map[string]interface{}) + if !ok { + t.Fatalf("expected map, got %T", got) + } + if m["text"] != "hello" { + t.Errorf("got %v, want text=hello", m) + } +} + func TestParseJSONMap_StripSingleQuotes_CmdExe(t *testing.T) { got, err := ParseJSONMap(`'{"key":"value"}'`, "--params", nil) if err != nil { From 60e19d76b3f2b71f97951697efa1fd809c076583 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=A2=81=E7=A1=95?= Date: Wed, 29 Apr 2026 18:14:13 +0800 Subject: [PATCH 2/2] refactor(cmdutil): route @file through fileio.FileIO abstraction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first cut of @file support called os.ReadFile directly inside ResolveInput, bypassing the codebase's fileio.FileIO abstraction (SafeInputPath validation, pluggable provider). That diverged from how every other file-reading path works: BuildFormdata for --file uploads and the shortcuts framework's resolveInputFlags both go through fileio.FileIO.Open with explicit fileio.ErrPathValidation handling. Re-route @file through the same path: - ResolveInput, ParseJSONMap, ParseOptionalBody now take a fileio.FileIO; @path uses fileIO.Open which goes through SafeInputPath (control-char rejection, abs-path rejection, symlink-escape check) — same security posture as --file - cmd/api and cmd/service callsites pass Factory.ResolveFileIO(ctx); the upload path now reuses the resolved fileIO instead of resolving twice - Path-validation errors surface as `--params: invalid file path "...": ...` distinct from `--params: cannot read file "...": ...` for genuine I/O errors - Nil fileIO with an @path returns a clear "file input (@path) is not available" error - Tests use localfileio.LocalFileIO with TestChdir(t, dir), matching the existing fileupload_test.go pattern; absolute-path rejection and nil-fileIO are covered This makes the feature behave identically under any FileIO provider (including server mode) instead of being silently bound to the local filesystem. Change-Id: I878c4e8fb03f43f1f19afad75ec3af9cdab7a7f9 --- cmd/api/api.go | 9 +-- cmd/service/service.go | 9 +-- internal/cmdutil/json.go | 13 +++-- internal/cmdutil/json_test.go | 4 +- internal/cmdutil/resolve.go | 24 ++++++-- internal/cmdutil/resolve_test.go | 94 +++++++++++++++++++++----------- 6 files changed, 101 insertions(+), 52 deletions(-) diff --git a/cmd/api/api.go b/cmd/api/api.go index a8de85ce9..83e963059 100644 --- a/cmd/api/api.go +++ b/cmd/api/api.go @@ -112,6 +112,7 @@ func NewCmdApiWithContext(ctx context.Context, f *cmdutil.Factory, runF func(*AP // FileUploadMeta is returned instead so the caller can render dry-run output. func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, *cmdutil.FileUploadMeta, error) { stdin := opts.Factory.IOStreams.In + fileIO := opts.Factory.ResolveFileIO(opts.Ctx) // Validate --file mutual exclusions first. if err := cmdutil.ValidateFileFlag(opts.File, opts.Params, opts.Data, opts.Output, opts.PageAll, opts.Method); err != nil { @@ -123,7 +124,7 @@ func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, *cmdutil.FileUploa return client.RawApiRequest{}, nil, output.ErrValidation("--params and --data cannot both read from stdin (-)") } - params, err := cmdutil.ParseJSONMap(opts.Params, "--params", stdin) + params, err := cmdutil.ParseJSONMap(opts.Params, "--params", stdin, fileIO) if err != nil { return client.RawApiRequest{}, nil, err } @@ -145,7 +146,7 @@ func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, *cmdutil.FileUploa // Parse --data as JSON map for form fields (not as body). var dataFields any if opts.Data != "" { - dataFields, err = cmdutil.ParseOptionalBody(opts.Method, opts.Data, stdin) + dataFields, err = cmdutil.ParseOptionalBody(opts.Method, opts.Data, stdin, fileIO) if err != nil { return client.RawApiRequest{}, nil, err } @@ -161,7 +162,7 @@ func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, *cmdutil.FileUploa } fd, err := cmdutil.BuildFormdata( - opts.Factory.ResolveFileIO(opts.Ctx), + fileIO, fieldName, filePath, isStdin, stdin, dataFields, ) if err != nil { @@ -171,7 +172,7 @@ func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, *cmdutil.FileUploa request.ExtraOpts = append(request.ExtraOpts, larkcore.WithFileUpload()) } else { // Normal path: JSON body. - data, err := cmdutil.ParseOptionalBody(opts.Method, opts.Data, stdin) + data, err := cmdutil.ParseOptionalBody(opts.Method, opts.Data, stdin, fileIO) if err != nil { return client.RawApiRequest{}, nil, err } diff --git a/cmd/service/service.go b/cmd/service/service.go index f7bd89cd8..20c5e025f 100644 --- a/cmd/service/service.go +++ b/cmd/service/service.go @@ -354,6 +354,7 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmd // stdin is an io.Reader consumed at most once. Only one of --params/--data // may use "-" (stdin); the conflict check below prevents silent data loss. stdin := opts.Factory.IOStreams.In + fileIO := opts.Factory.ResolveFileIO(opts.Ctx) // Validate --file mutual exclusions. if err := cmdutil.ValidateFileFlag(opts.File, opts.Params, opts.Data, opts.Output, opts.PageAll, httpMethod); err != nil { @@ -362,7 +363,7 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmd if opts.Params == "-" && opts.Data == "-" { return client.RawApiRequest{}, nil, output.ErrValidation("--params and --data cannot both read from stdin (-)") } - params, err := cmdutil.ParseJSONMap(opts.Params, "--params", stdin) + params, err := cmdutil.ParseJSONMap(opts.Params, "--params", stdin, fileIO) if err != nil { return client.RawApiRequest{}, nil, err } @@ -431,7 +432,7 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmd // Parse --data as form fields. var dataFields any if opts.Data != "" { - dataFields, err = cmdutil.ParseOptionalBody(httpMethod, opts.Data, stdin) + dataFields, err = cmdutil.ParseOptionalBody(httpMethod, opts.Data, stdin, fileIO) if err != nil { return client.RawApiRequest{}, nil, err } @@ -447,7 +448,7 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmd } fd, err := cmdutil.BuildFormdata( - opts.Factory.ResolveFileIO(opts.Ctx), + fileIO, fieldName, filePath, isStdin, stdin, dataFields, ) if err != nil { @@ -456,7 +457,7 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmd request.Data = fd request.ExtraOpts = append(request.ExtraOpts, larkcore.WithFileUpload()) } else { - data, err := cmdutil.ParseOptionalBody(httpMethod, opts.Data, stdin) + data, err := cmdutil.ParseOptionalBody(httpMethod, opts.Data, stdin, fileIO) if err != nil { return client.RawApiRequest{}, nil, err } diff --git a/internal/cmdutil/json.go b/internal/cmdutil/json.go index 65c639b46..817aad446 100644 --- a/internal/cmdutil/json.go +++ b/internal/cmdutil/json.go @@ -7,19 +7,20 @@ import ( "encoding/json" "io" + "github.com/larksuite/cli/extension/fileio" "github.com/larksuite/cli/internal/output" ) // ParseOptionalBody parses --data JSON for methods that accept a request body. -// Supports stdin (-) and single-quote stripping via ResolveInput. +// Supports stdin (-), @file, @@-escape, and single-quote stripping via ResolveInput. // Returns (nil, nil) if the method has no body or data is empty. -func ParseOptionalBody(httpMethod, data string, stdin io.Reader) (interface{}, error) { +func ParseOptionalBody(httpMethod, data string, stdin io.Reader, fileIO fileio.FileIO) (interface{}, error) { switch httpMethod { case "POST", "PUT", "PATCH", "DELETE": default: return nil, nil } - resolved, err := ResolveInput(data, stdin) + resolved, err := ResolveInput(data, stdin, fileIO) if err != nil { return nil, output.ErrValidation("--data: %s", err) } @@ -34,9 +35,9 @@ func ParseOptionalBody(httpMethod, data string, stdin io.Reader) (interface{}, e } // ParseJSONMap parses a JSON string into a map. Returns an empty map if input is empty. -// Supports stdin (-) and single-quote stripping via ResolveInput. -func ParseJSONMap(input, label string, stdin io.Reader) (map[string]any, error) { - resolved, err := ResolveInput(input, stdin) +// Supports stdin (-), @file, @@-escape, and single-quote stripping via ResolveInput. +func ParseJSONMap(input, label string, stdin io.Reader, fileIO fileio.FileIO) (map[string]any, error) { + resolved, err := ResolveInput(input, stdin, fileIO) if err != nil { return nil, output.ErrValidation("%s: %s", label, err) } diff --git a/internal/cmdutil/json_test.go b/internal/cmdutil/json_test.go index fed7f927c..44a7a51d7 100644 --- a/internal/cmdutil/json_test.go +++ b/internal/cmdutil/json_test.go @@ -23,7 +23,7 @@ func TestParseOptionalBody(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := ParseOptionalBody(tt.method, tt.data, nil) + got, err := ParseOptionalBody(tt.method, tt.data, nil, nil) if (err != nil) != tt.wantErr { t.Errorf("ParseOptionalBody() error = %v, wantErr %v", err, tt.wantErr) return @@ -53,7 +53,7 @@ func TestParseJSONMap(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := ParseJSONMap(tt.input, tt.label, nil) + got, err := ParseJSONMap(tt.input, tt.label, nil, nil) if (err != nil) != tt.wantErr { t.Errorf("ParseJSONMap() error = %v, wantErr %v", err, tt.wantErr) return diff --git a/internal/cmdutil/resolve.go b/internal/cmdutil/resolve.go index 44d8d9943..9a0a8e753 100644 --- a/internal/cmdutil/resolve.go +++ b/internal/cmdutil/resolve.go @@ -4,22 +4,27 @@ package cmdutil import ( + "errors" "fmt" "io" - "os" "strings" + + "github.com/larksuite/cli/extension/fileio" ) // ResolveInput resolves special input conventions for a raw flag value: // - "-" → read all bytes from stdin -// - "@" → read all bytes from the file at +// - "@" → read all bytes from the file at via fileIO // - "@@..." → strip leading @ (escape for a literal @-prefixed value) // - "'...'" → strip surrounding single quotes (Windows cmd.exe compatibility) // - other → return as-is // +// fileIO is required for "@" inputs and goes through path validation +// (SafeInputPath); pass nil only when callers know "@" inputs are not possible. +// // Allows callers to bypass shell quoting issues (especially Windows PowerShell 5) // by reading JSON from a file (@path) or piping via stdin (-). -func ResolveInput(raw string, stdin io.Reader) (string, error) { +func ResolveInput(raw string, stdin io.Reader, fileIO fileio.FileIO) (string, error) { if raw == "" { return "", nil } @@ -51,7 +56,18 @@ func ResolveInput(raw string, stdin io.Reader) (string, error) { if path == "" { return "", fmt.Errorf("file path cannot be empty after @") } - data, err := os.ReadFile(path) + if fileIO == nil { + return "", fmt.Errorf("file input (@path) is not available in this context") + } + f, err := fileIO.Open(path) + if err != nil { + if errors.Is(err, fileio.ErrPathValidation) { + return "", fmt.Errorf("invalid file path %q: %w", path, err) + } + return "", fmt.Errorf("cannot read file %q: %w", path, err) + } + defer f.Close() + data, err := io.ReadAll(f) if err != nil { return "", fmt.Errorf("cannot read file %q: %w", path, err) } diff --git a/internal/cmdutil/resolve_test.go b/internal/cmdutil/resolve_test.go index 7fb85d590..54ac251a2 100644 --- a/internal/cmdutil/resolve_test.go +++ b/internal/cmdutil/resolve_test.go @@ -6,13 +6,14 @@ package cmdutil import ( "fmt" "os" - "path/filepath" "strings" "testing" + + "github.com/larksuite/cli/internal/vfs/localfileio" ) func TestResolveInput_Stdin(t *testing.T) { - got, err := ResolveInput("-", strings.NewReader(`{"key":"value"}`)) + got, err := ResolveInput("-", strings.NewReader(`{"key":"value"}`), nil) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -22,7 +23,7 @@ func TestResolveInput_Stdin(t *testing.T) { } func TestResolveInput_Stdin_TrimNewline(t *testing.T) { - got, err := ResolveInput("-", strings.NewReader("{\"k\":\"v\"}\n")) + got, err := ResolveInput("-", strings.NewReader("{\"k\":\"v\"}\n"), nil) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -32,7 +33,7 @@ func TestResolveInput_Stdin_TrimNewline(t *testing.T) { } func TestResolveInput_Stdin_Empty(t *testing.T) { - _, err := ResolveInput("-", strings.NewReader("")) + _, err := ResolveInput("-", strings.NewReader(""), nil) if err == nil { t.Error("expected error for empty stdin") } @@ -46,21 +47,21 @@ type errorReader struct{} func (errorReader) Read([]byte) (int, error) { return 0, fmt.Errorf("disk failure") } func TestResolveInput_Stdin_ReadError(t *testing.T) { - _, err := ResolveInput("-", errorReader{}) + _, err := ResolveInput("-", errorReader{}, nil) if err == nil || !strings.Contains(err.Error(), "failed to read stdin") { t.Errorf("expected read error, got: %v", err) } } func TestResolveInput_Stdin_WhitespaceOnly(t *testing.T) { - _, err := ResolveInput("-", strings.NewReader(" \n\t\n ")) + _, err := ResolveInput("-", strings.NewReader(" \n\t\n "), nil) if err == nil { t.Error("expected error for whitespace-only stdin") } } func TestResolveInput_Stdin_Nil(t *testing.T) { - _, err := ResolveInput("-", nil) + _, err := ResolveInput("-", nil, nil) if err == nil { t.Error("expected error for nil stdin") } @@ -79,7 +80,7 @@ func TestResolveInput_StripSingleQuotes(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := ResolveInput(tt.in, nil) + got, err := ResolveInput(tt.in, nil, nil) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -91,7 +92,7 @@ func TestResolveInput_StripSingleQuotes(t *testing.T) { } func TestResolveInput_Empty(t *testing.T) { - got, err := ResolveInput("", nil) + got, err := ResolveInput("", nil, nil) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -101,7 +102,7 @@ func TestResolveInput_Empty(t *testing.T) { } func TestResolveInput_PlainValue(t *testing.T) { - got, err := ResolveInput(`{"already":"valid"}`, nil) + got, err := ResolveInput(`{"already":"valid"}`, nil, nil) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -111,12 +112,13 @@ func TestResolveInput_PlainValue(t *testing.T) { } func TestResolveInput_AtFile(t *testing.T) { + fio := &localfileio.LocalFileIO{} dir := t.TempDir() - path := filepath.Join(dir, "params.json") - if err := os.WriteFile(path, []byte(`{"folder_token":"abc123"}`), 0o600); err != nil { + TestChdir(t, dir) + if err := os.WriteFile("params.json", []byte(`{"folder_token":"abc123"}`), 0o600); err != nil { t.Fatal(err) } - got, err := ResolveInput("@"+path, nil) + got, err := ResolveInput("@params.json", nil, fio) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -126,12 +128,13 @@ func TestResolveInput_AtFile(t *testing.T) { } func TestResolveInput_AtFile_TrimsWhitespace(t *testing.T) { + fio := &localfileio.LocalFileIO{} dir := t.TempDir() - path := filepath.Join(dir, "p.json") - if err := os.WriteFile(path, []byte("\n {\"k\":\"v\"}\n"), 0o600); err != nil { + TestChdir(t, dir) + if err := os.WriteFile("p.json", []byte("\n {\"k\":\"v\"}\n"), 0o600); err != nil { t.Fatal(err) } - got, err := ResolveInput("@"+path, nil) + got, err := ResolveInput("@p.json", nil, fio) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -141,33 +144,58 @@ func TestResolveInput_AtFile_TrimsWhitespace(t *testing.T) { } func TestResolveInput_AtFile_NotFound(t *testing.T) { - _, err := ResolveInput("@/no/such/file.json", nil) + fio := &localfileio.LocalFileIO{} + dir := t.TempDir() + TestChdir(t, dir) + _, err := ResolveInput("@missing.json", nil, fio) if err == nil || !strings.Contains(err.Error(), "cannot read file") { t.Errorf("expected read error, got: %v", err) } } +func TestResolveInput_AtFile_PathValidation(t *testing.T) { + fio := &localfileio.LocalFileIO{} + dir := t.TempDir() + TestChdir(t, dir) + // Absolute paths are rejected by SafeInputPath; the error must surface + // as an invalid-path message, not a generic read failure. + _, err := ResolveInput("@/etc/passwd", nil, fio) + if err == nil || !strings.Contains(err.Error(), "invalid file path") { + t.Errorf("expected path-validation error, got: %v", err) + } +} + func TestResolveInput_AtFile_EmptyPath(t *testing.T) { - _, err := ResolveInput("@", nil) + fio := &localfileio.LocalFileIO{} + _, err := ResolveInput("@", nil, fio) if err == nil || !strings.Contains(err.Error(), "file path cannot be empty") { t.Errorf("expected empty-path error, got: %v", err) } } func TestResolveInput_AtFile_EmptyContent(t *testing.T) { + fio := &localfileio.LocalFileIO{} dir := t.TempDir() - path := filepath.Join(dir, "empty.json") - if err := os.WriteFile(path, []byte(" \n"), 0o600); err != nil { + TestChdir(t, dir) + if err := os.WriteFile("empty.json", []byte(" \n"), 0o600); err != nil { t.Fatal(err) } - _, err := ResolveInput("@"+path, nil) + _, err := ResolveInput("@empty.json", nil, fio) if err == nil || !strings.Contains(err.Error(), "is empty") { t.Errorf("expected empty-file error, got: %v", err) } } +func TestResolveInput_AtFile_NoFileIO(t *testing.T) { + // When fileIO is nil, @path must error rather than silently fall back. + _, err := ResolveInput("@params.json", nil, nil) + if err == nil || !strings.Contains(err.Error(), "not available") { + t.Errorf("expected unavailable error, got: %v", err) + } +} + func TestResolveInput_DoubleAtEscape(t *testing.T) { - got, err := ResolveInput("@@literal", nil) + got, err := ResolveInput("@@literal", nil, nil) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -179,7 +207,7 @@ func TestResolveInput_DoubleAtEscape(t *testing.T) { // Integration: ResolveInput flows through ParseJSONMap correctly. func TestParseJSONMap_WithStdin(t *testing.T) { stdin := strings.NewReader(`{"message_id":"om_xxx","user_id_type":"open_id"}`) - got, err := ParseJSONMap("-", "--params", stdin) + got, err := ParseJSONMap("-", "--params", stdin, nil) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -190,12 +218,13 @@ func TestParseJSONMap_WithStdin(t *testing.T) { // Integration: @file flows through ParseJSONMap correctly. func TestParseJSONMap_WithAtFile(t *testing.T) { + fio := &localfileio.LocalFileIO{} dir := t.TempDir() - path := filepath.Join(dir, "params.json") - if err := os.WriteFile(path, []byte(`{"folder_token":"abc123","type":"folder"}`), 0o600); err != nil { + TestChdir(t, dir) + if err := os.WriteFile("params.json", []byte(`{"folder_token":"abc123","type":"folder"}`), 0o600); err != nil { t.Fatal(err) } - got, err := ParseJSONMap("@"+path, "--params", nil) + got, err := ParseJSONMap("@params.json", "--params", nil, fio) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -208,12 +237,13 @@ func TestParseJSONMap_WithAtFile(t *testing.T) { } func TestParseOptionalBody_WithAtFile(t *testing.T) { + fio := &localfileio.LocalFileIO{} dir := t.TempDir() - path := filepath.Join(dir, "data.json") - if err := os.WriteFile(path, []byte(`{"text":"hello"}`), 0o600); err != nil { + TestChdir(t, dir) + if err := os.WriteFile("data.json", []byte(`{"text":"hello"}`), 0o600); err != nil { t.Fatal(err) } - got, err := ParseOptionalBody("POST", "@"+path, nil) + got, err := ParseOptionalBody("POST", "@data.json", nil, fio) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -227,7 +257,7 @@ func TestParseOptionalBody_WithAtFile(t *testing.T) { } func TestParseJSONMap_StripSingleQuotes_CmdExe(t *testing.T) { - got, err := ParseJSONMap(`'{"key":"value"}'`, "--params", nil) + got, err := ParseJSONMap(`'{"key":"value"}'`, "--params", nil, nil) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -238,7 +268,7 @@ func TestParseJSONMap_StripSingleQuotes_CmdExe(t *testing.T) { func TestParseOptionalBody_WithStdin(t *testing.T) { stdin := strings.NewReader(`{"text":"hello"}`) - got, err := ParseOptionalBody("POST", "-", stdin) + got, err := ParseOptionalBody("POST", "-", stdin, nil) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -271,7 +301,7 @@ func TestParseJSONMap_WindowsShellScenarios(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := ParseJSONMap(tt.input, "--params", nil) + got, err := ParseJSONMap(tt.input, "--params", nil, nil) if (err != nil) != tt.wantErr { t.Errorf("error = %v, wantErr %v", err, tt.wantErr) return