Files

713 lines
19 KiB
Go

// 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"
)
// writeTestFile is a test helper that writes data to a file and fails the
// test immediately if the write fails, preventing confusing downstream errors.
func writeTestFile(t *testing.T, path string, data []byte) {
t.Helper()
if err := os.WriteFile(path, data, 0o644); err != nil {
t.Fatalf("failed to write test file %s: %v", path, err)
}
}
// ---------------------------------------------------------------------------
// ReadFileArg
// ---------------------------------------------------------------------------
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")
}
}
// TestReadFileArgChineseAtMention guards the @所有人-style mentions that the
// chat bot Webhook tests rely on: an '@' followed by non-ASCII text must be
// treated as a literal message, not as the @file injection syntax.
func TestReadFileArgChineseAtMention(t *testing.T) {
t.Parallel()
cases := []string{
"@所有人 这是 @ 所有人 的消息",
"@张三",
"@A 但接下来都是中文@测试",
}
for _, in := range cases {
val, isFile, err := ReadFileArg(in)
if in == "@A 但接下来都是中文@测试" {
// '@A' starts with ASCII, treated as path → expect file error
if err == nil {
t.Errorf("@A... should attempt file lookup; got val=%q isFile=%v", val, isFile)
}
continue
}
if err != nil {
t.Errorf("%q: unexpected error %v", in, err)
continue
}
if isFile {
t.Errorf("%q: should be plain text, got isFile=true", in)
}
if val != in {
t.Errorf("%q: got %q", in, val)
}
}
}
func TestReadFileArgReadsFile(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path := filepath.Join(dir, "input.txt")
writeTestFile(t, path, []byte(`{"title":"test"}`))
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, err := os.Create(path)
if err != nil {
t.Fatalf("failed to create test file: %v", err)
}
data := strings.Repeat("x", maxStdinSize+1)
if _, err := f.WriteString(data); err != nil {
f.Close()
t.Fatalf("failed to write test data: %v", err)
}
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 TestReadFileArgAtDashPassThrough(t *testing.T) {
t.Parallel()
// @- means stdin — ReadFileArg should NOT handle it, just pass through.
val, isFile, err := ReadFileArg("@-")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if isFile {
t.Error("@- should not be treated as a file by ReadFileArg")
}
if val != "@-" {
t.Errorf("got %q, want %q", val, "@-")
}
}
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)
}
}
func TestStdinIsPipeReturnsFalseForTerminal(t *testing.T) {
// go test runs with stdin as a terminal, so StdinIsPipe should return false.
if StdinIsPipe() {
t.Error("expected StdinIsPipe() == false in terminal context")
}
}
// ---------------------------------------------------------------------------
// readFileBounded (via ReadFileArg)
// ---------------------------------------------------------------------------
func TestReadFileBoundedEmptyFile(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path := filepath.Join(dir, "empty.txt")
writeTestFile(t, path, []byte(""))
val, isFile, err := ReadFileArg("@" + path)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !isFile {
t.Error("should be detected as file")
}
if val != "" {
t.Errorf("expected empty content, got %q", val)
}
}
func TestReadFileBoundedRejectsDirectory(t *testing.T) {
t.Parallel()
dir := t.TempDir()
_, _, err := ReadFileArg("@" + dir)
if err == nil {
t.Fatal("expected error when reading a directory as a file")
}
}
// ---------------------------------------------------------------------------
// StdinGuard
// ---------------------------------------------------------------------------
func TestStdinGuardFirstClaimSucceeds(t *testing.T) {
t.Parallel()
guard := NewStdinGuard()
if err := guard.Claim("--text @-"); err != nil {
t.Fatalf("first claim should succeed: %v", err)
}
if !guard.Claimed() {
t.Error("guard should report claimed after successful claim")
}
}
func TestStdinGuardSecondClaimFails(t *testing.T) {
t.Parallel()
guard := NewStdinGuard()
_ = guard.Claim("--text @-")
err := guard.Claim("--body @-")
if err == nil {
t.Fatal("second claim should fail")
}
if !strings.Contains(err.Error(), "--text @-") {
t.Errorf("error should mention first claimer, got: %v", err)
}
if !strings.Contains(err.Error(), "--body @-") {
t.Errorf("error should mention second claimer, got: %v", err)
}
}
func TestStdinGuardNotClaimedInitially(t *testing.T) {
t.Parallel()
guard := NewStdinGuard()
if guard.Claimed() {
t.Error("fresh guard should not be claimed")
}
}
// ---------------------------------------------------------------------------
// ResolveInputSource
// ---------------------------------------------------------------------------
func TestResolveInputSourcePlainValue(t *testing.T) {
t.Parallel()
guard := NewStdinGuard()
val, err := ResolveInputSource("hello", "text", guard)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if val != "hello" {
t.Errorf("got %q, want %q", val, "hello")
}
if guard.Claimed() {
t.Error("plain value should not claim stdin")
}
}
func TestResolveInputSourceEmptyValue(t *testing.T) {
t.Parallel()
guard := NewStdinGuard()
val, err := ResolveInputSource("", "text", guard)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if val != "" {
t.Errorf("got %q, want empty", val)
}
}
func TestResolveInputSourceAtFileReadsFile(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path := filepath.Join(dir, "msg.txt")
writeTestFile(t, path, []byte("file content here"))
guard := NewStdinGuard()
val, err := ResolveInputSource("@"+path, "text", guard)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if val != "file content here" {
t.Errorf("got %q, want %q", val, "file content here")
}
// @file should NOT claim stdin.
if guard.Claimed() {
t.Error("@file should not claim stdin")
}
}
func TestResolveInputSourceAtFileEmptyName(t *testing.T) {
t.Parallel()
guard := NewStdinGuard()
_, err := ResolveInputSource("@", "json", guard)
if err == nil {
t.Fatal("expected error for empty @file name")
}
if !strings.Contains(err.Error(), "--json") {
t.Errorf("error should mention flag name, got: %v", err)
}
}
func TestResolveInputSourceAtFileMissing(t *testing.T) {
t.Parallel()
guard := NewStdinGuard()
_, err := ResolveInputSource("@/no/such/file.txt", "data", guard)
if err == nil {
t.Fatal("expected error for missing file")
}
if !strings.Contains(err.Error(), "--data") {
t.Errorf("error should mention flag name, got: %v", err)
}
}
func TestResolveInputSourceAtFileSizeLimit(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path := filepath.Join(dir, "huge.bin")
f, err := os.Create(path)
if err != nil {
t.Fatalf("failed to create test file: %v", err)
}
if _, err := f.WriteString(strings.Repeat("x", maxStdinSize+1)); err != nil {
f.Close()
t.Fatalf("failed to write test data: %v", err)
}
f.Close()
guard := NewStdinGuard()
_, resolveErr := ResolveInputSource("@"+path, "body", guard)
if resolveErr == nil {
t.Fatal("expected error for oversized file")
}
if !strings.Contains(resolveErr.Error(), "10 MB") {
t.Errorf("error should mention size limit, got: %v", resolveErr)
}
}
func TestResolveInputSourceAtDashNilGuardFails(t *testing.T) {
t.Parallel()
_, err := ResolveInputSource("@-", "text", nil)
if err == nil {
t.Fatal("expected error when guard is nil")
}
if !strings.Contains(err.Error(), "not available") {
t.Errorf("error should mention stdin unavailability, got: %v", err)
}
}
func TestResolveInputSourceAtDashDoubleClaimFails(t *testing.T) {
t.Parallel()
guard := NewStdinGuard()
// Simulate first claim from another source.
_ = guard.Claim("--json @-")
_, err := ResolveInputSource("@-", "text", guard)
if err == nil {
t.Fatal("expected error for double stdin claim")
}
if !strings.Contains(err.Error(), "already consumed") {
t.Errorf("error should mention stdin conflict, got: %v", err)
}
}
func TestResolveInputSourceAtFileMultiline(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path := filepath.Join(dir, "multiline.md")
content := "# Title\n\nLine 1\nLine 2\nLine 3\n"
writeTestFile(t, path, []byte(content))
guard := NewStdinGuard()
val, err := ResolveInputSource("@"+path, "text", guard)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if val != content {
t.Errorf("multiline content mismatch:\ngot: %q\nwant: %q", val, content)
}
}
func TestResolveInputSourceAtFileUTF8(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path := filepath.Join(dir, "chinese.txt")
content := "你好世界 🌍"
writeTestFile(t, path, []byte(content))
guard := NewStdinGuard()
val, err := ResolveInputSource("@"+path, "text", guard)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if val != content {
t.Errorf("UTF-8 content mismatch:\ngot: %q\nwant: %q", val, content)
}
}
func TestResolveInputSourceAtFileJSON(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path := filepath.Join(dir, "payload.json")
content := `{"title":"meeting","startTime":"2026-03-29T10:00:00Z"}`
writeTestFile(t, path, []byte(content))
guard := NewStdinGuard()
val, err := ResolveInputSource("@"+path, "json", guard)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if val != content {
t.Errorf("JSON content mismatch:\ngot: %q\nwant: %q", val, content)
}
}
func TestResolveInputSourceValueStartingWithAtSign(t *testing.T) {
t.Parallel()
// A value like "@mention" that looks like @file but the file doesn't exist
// should return an error (user likely intended file input).
guard := NewStdinGuard()
_, err := ResolveInputSource("@mention_someone", "text", guard)
if err == nil {
t.Fatal("expected error for non-existent @file path")
}
}
// ---------------------------------------------------------------------------
// ResolveInputSource: table-driven edge cases
// ---------------------------------------------------------------------------
func TestResolveInputSourceTable(t *testing.T) {
t.Parallel()
dir := t.TempDir()
existingFile := filepath.Join(dir, "exists.txt")
writeTestFile(t, existingFile, []byte("file-data"))
tests := []struct {
name string
value string
flagName string
guard *StdinGuard
wantVal string
wantErr string // substring to match in error, "" means no error
wantClaim bool // expect guard to be claimed after call
}{
{
name: "plain string unchanged",
value: "hello",
flagName: "text",
guard: NewStdinGuard(),
wantVal: "hello",
},
{
name: "empty string unchanged",
value: "",
flagName: "text",
guard: NewStdinGuard(),
wantVal: "",
},
{
name: "plain string with special chars",
value: "hello@world.com",
flagName: "email",
guard: NewStdinGuard(),
wantVal: "hello@world.com",
},
{
name: "@file reads content",
value: "@" + existingFile,
flagName: "data",
guard: NewStdinGuard(),
wantVal: "file-data",
},
{
name: "@file does not claim stdin",
value: "@" + existingFile,
flagName: "data",
guard: NewStdinGuard(),
wantVal: "file-data",
wantClaim: false,
},
{
name: "bare @ is error",
value: "@",
flagName: "body",
guard: NewStdinGuard(),
wantErr: "must not be empty",
},
{
name: "missing file is error",
value: "@/tmp/does-not-exist-" + t.Name(),
flagName: "file",
guard: NewStdinGuard(),
wantErr: "--file",
},
{
name: "@- with nil guard is error",
value: "@-",
flagName: "text",
guard: nil,
wantErr: "not available",
},
{
name: "@- with pre-claimed guard is error",
value: "@-",
flagName: "body",
guard: func() *StdinGuard {
g := NewStdinGuard()
_ = g.Claim("--json @-")
return g
}(),
wantErr: "already consumed",
},
{
name: "error message includes flag name",
value: "@/nonexistent",
flagName: "my-flag",
guard: NewStdinGuard(),
wantErr: "--my-flag",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
val, err := ResolveInputSource(tt.value, tt.flagName, tt.guard)
if tt.wantErr != "" {
if err == nil {
t.Fatalf("expected error containing %q, got nil", tt.wantErr)
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Errorf("error = %q, want substring %q", err.Error(), tt.wantErr)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if val != tt.wantVal {
t.Errorf("value = %q, want %q", val, tt.wantVal)
}
if tt.guard != nil && tt.wantClaim != tt.guard.Claimed() {
t.Errorf("guard.Claimed() = %v, want %v", tt.guard.Claimed(), tt.wantClaim)
}
})
}
}
// ---------------------------------------------------------------------------
// ReadFileArg: table-driven edge cases
// ---------------------------------------------------------------------------
func TestReadFileArgTable(t *testing.T) {
t.Parallel()
dir := t.TempDir()
existingFile := filepath.Join(dir, "data.txt")
writeTestFile(t, existingFile, []byte("content"))
emptyFile := filepath.Join(dir, "empty.txt")
writeTestFile(t, emptyFile, []byte(""))
tests := []struct {
name string
value string
wantVal string
wantIsFile bool
wantErr string
}{
{"plain string", "hello", "hello", false, ""},
{"empty string", "", "", false, ""},
{"email-like value", "user@domain.com", "user@domain.com", false, ""},
{"@file reads content", "@" + existingFile, "content", true, ""},
{"@file empty content", "@" + emptyFile, "", true, ""},
{"@- passes through", "@-", "@-", false, ""},
{"bare @ is error", "@", "", false, "must not be empty"},
{"missing file is error", "@/nonexistent", "", false, "@file"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
val, isFile, err := ReadFileArg(tt.value)
if tt.wantErr != "" {
if err == nil {
t.Fatalf("expected error containing %q, got nil", tt.wantErr)
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Errorf("error = %q, want substring %q", err.Error(), tt.wantErr)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if val != tt.wantVal {
t.Errorf("value = %q, want %q", val, tt.wantVal)
}
if isFile != tt.wantIsFile {
t.Errorf("isFile = %v, want %v", isFile, tt.wantIsFile)
}
})
}
}
// ---------------------------------------------------------------------------
// StdinGuard: table-driven claim sequences
// ---------------------------------------------------------------------------
func TestStdinGuardClaimSequences(t *testing.T) {
t.Parallel()
tests := []struct {
name string
claims []string // sources to claim in order
wantFails int // how many claims should fail
wantErrSub string // substring expected in first failure
}{
{
name: "single claim succeeds",
claims: []string{"--json @-"},
wantFails: 0,
},
{
name: "second claim fails",
claims: []string{"--json @-", "--text @-"},
wantFails: 1,
wantErrSub: "already consumed",
},
{
name: "third claim also fails",
claims: []string{"--json @-", "--text @-", "--body @-"},
wantFails: 2,
wantErrSub: "already consumed",
},
{
name: "error names both sources",
claims: []string{"implicit stdin (pipe)", "--text @-"},
wantFails: 1,
wantErrSub: "implicit stdin (pipe)",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
guard := NewStdinGuard()
fails := 0
var firstErr error
for _, source := range tt.claims {
if err := guard.Claim(source); err != nil {
fails++
if firstErr == nil {
firstErr = err
}
}
}
if fails != tt.wantFails {
t.Errorf("failures = %d, want %d", fails, tt.wantFails)
}
if tt.wantErrSub != "" && firstErr != nil {
if !strings.Contains(firstErr.Error(), tt.wantErrSub) {
t.Errorf("first error = %q, want substring %q", firstErr.Error(), tt.wantErrSub)
}
}
})
}
}
// ---------------------------------------------------------------------------
// StdinGuard concurrency safety
// ---------------------------------------------------------------------------
func TestStdinGuardConcurrentClaims(t *testing.T) {
t.Parallel()
guard := NewStdinGuard()
const goroutines = 50
results := make(chan error, goroutines)
for i := 0; i < goroutines; i++ {
go func(id int) {
results <- guard.Claim("goroutine")
}(i)
}
successCount := 0
for i := 0; i < goroutines; i++ {
if err := <-results; err == nil {
successCount++
}
}
if successCount != 1 {
t.Errorf("exactly one goroutine should succeed, got %d", successCount)
}
}