diff --git a/internal/cli/canonical.go b/internal/cli/canonical.go index 1869b5b5..cf3c341a 100644 --- a/internal/cli/canonical.go +++ b/internal/cli/canonical.go @@ -307,6 +307,23 @@ func newToolCommand(product ir.CanonicalProduct, tool ir.ToolDescriptor, runner if err != nil { return apperrors.NewInternal("failed to read --json") } + + // Support @file syntax for --json: read file contents as JSON payload. + if content, isFile, fileErr := ReadFileArg(jsonPayload); fileErr != nil { + return fileErr + } else if isFile { + jsonPayload = content + } + + // Support stdin pipe: if no --json given, read from pipe. + if jsonPayload == "" { + if stdinData, stdinErr := ReadStdinIfPiped(); stdinErr != nil { + return stdinErr + } else if stdinData != "" { + jsonPayload = stdinData + } + } + paramsPayload, err := cmd.Flags().GetString("params") if err != nil { return apperrors.NewInternal("failed to read --params") diff --git a/internal/cli/stdin.go b/internal/cli/stdin.go new file mode 100644 index 00000000..35d9a922 --- /dev/null +++ b/internal/cli/stdin.go @@ -0,0 +1,76 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cli + +import ( + "io" + "os" + "strings" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" +) + +const ( + // maxStdinSize limits the amount of data read from stdin or @file + // to prevent memory exhaustion from accidental large pipes. + maxStdinSize = 10 * 1024 * 1024 // 10 MB +) + +// ReadStdinIfPiped reads all data from stdin if it is a pipe (not a terminal). +// Returns empty string if stdin is a terminal or has no data. +func ReadStdinIfPiped() (string, error) { + info, err := os.Stdin.Stat() + if err != nil { + return "", nil + } + // Check if stdin is a pipe (not a character device / terminal). + if info.Mode()&os.ModeCharDevice != 0 { + return "", nil + } + data, err := io.ReadAll(io.LimitReader(os.Stdin, maxStdinSize+1)) + if err != nil { + return "", apperrors.NewValidation("failed to read stdin: " + err.Error()) + } + if int64(len(data)) > maxStdinSize { + return "", apperrors.NewValidation("stdin input exceeds 10 MB limit") + } + return string(data), nil +} + +// ReadFileArg reads the contents of a file referenced by the @filename syntax. +// Returns the original value unchanged if it does not start with "@". +// Returns an error if the file cannot be read or exceeds the size limit. +func ReadFileArg(value string) (string, bool, error) { + if !strings.HasPrefix(value, "@") { + return value, false, nil + } + path := value[1:] + if path == "" { + return "", false, apperrors.NewValidation("@file: filename must not be empty") + } + + info, err := os.Stat(path) + if err != nil { + return "", false, apperrors.NewValidation("@file: " + err.Error()) + } + if info.Size() > maxStdinSize { + return "", false, apperrors.NewValidation("@file: file exceeds 10 MB limit") + } + + data, err := os.ReadFile(path) + if err != nil { + return "", false, apperrors.NewValidation("@file: " + err.Error()) + } + return string(data), true, nil +} diff --git a/internal/cli/stdin_test.go b/internal/cli/stdin_test.go new file mode 100644 index 00000000..0d0cfb30 --- /dev/null +++ b/internal/cli/stdin_test.go @@ -0,0 +1,105 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cli + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestReadFileArgPlainValue(t *testing.T) { + t.Parallel() + val, isFile, err := ReadFileArg("hello world") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if isFile { + t.Error("plain value should not be detected as file") + } + if val != "hello world" { + t.Errorf("got %q, want %q", val, "hello world") + } +} + +func TestReadFileArgReadsFile(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + path := filepath.Join(dir, "input.txt") + os.WriteFile(path, []byte(`{"title":"test"}`), 0o644) + + val, isFile, err := ReadFileArg("@" + path) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !isFile { + t.Error("@file value should be detected as file") + } + if val != `{"title":"test"}` { + t.Errorf("got %q, want %q", val, `{"title":"test"}`) + } +} + +func TestReadFileArgEmptyFilename(t *testing.T) { + t.Parallel() + _, _, err := ReadFileArg("@") + if err == nil { + t.Fatal("expected error for empty filename") + } + if !strings.Contains(err.Error(), "must not be empty") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestReadFileArgMissingFile(t *testing.T) { + t.Parallel() + _, _, err := ReadFileArg("@/nonexistent/path/file.txt") + if err == nil { + t.Fatal("expected error for missing file") + } +} + +func TestReadFileArgSizeLimit(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + path := filepath.Join(dir, "huge.txt") + // Create a file slightly over maxStdinSize (write 10MB + 1 byte) + f, _ := os.Create(path) + data := strings.Repeat("x", maxStdinSize+1) + f.WriteString(data) + f.Close() + + _, _, err := ReadFileArg("@" + path) + if err == nil { + t.Fatal("expected error for oversized file") + } + if !strings.Contains(err.Error(), "10 MB") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestReadStdinIfPipedReturnsEmptyForTerminal(t *testing.T) { + // This test runs in a terminal context (go test), so stdin is a terminal. + // ReadStdinIfPiped should return empty string. + val, err := ReadStdinIfPiped() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if val != "" { + t.Errorf("expected empty string for terminal stdin, got %q", val) + } +}