Compare commits

..
4 changed files with 226 additions and 7 deletions
+25 -4
View File
@@ -15,8 +15,8 @@ const defaultTextInputLimit = int64(8 << 20)
var (
readTextInputAll = io.ReadAll
readTextInputStat = os.Stat
readTextInputFile = os.ReadFile
statTextInputPath = os.Stat
openTextInputFile = os.Open
readTextInputRel = filepath.Rel
)
@@ -41,6 +41,9 @@ func ReadTextInput(spec string, stdin io.Reader, maxBytes int64) (string, error)
return string(data), nil
}
if !strings.HasPrefix(spec, "@") {
if int64(len(spec)) > maxBytes {
return "", fmt.Errorf("LOCAL_INPUT_TOO_LARGE: 文本输入超过 %d 字节", maxBytes)
}
return spec, nil
}
path := strings.TrimSpace(strings.TrimPrefix(spec, "@"))
@@ -70,7 +73,22 @@ func ReadTextInput(spec string, stdin io.Reader, maxBytes int64) (string, error)
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(rel) {
return "", fmt.Errorf("LOCAL_INPUT_UNSAFE: @file 解析后逃逸工作目录")
}
info, err := readTextInputStat(realPath)
pathInfo, err := statTextInputPath(realPath)
if err != nil {
return "", fmt.Errorf("LOCAL_INPUT_READ_FAILED: %w", err)
}
if !pathInfo.Mode().IsRegular() {
return "", fmt.Errorf("LOCAL_INPUT_INVALID: @file 必须是普通文件")
}
if pathInfo.Size() > maxBytes {
return "", fmt.Errorf("LOCAL_INPUT_TOO_LARGE: 文件大小 %d 超过 %d 字节", pathInfo.Size(), maxBytes)
}
file, err := openTextInputFile(realPath)
if err != nil {
return "", fmt.Errorf("LOCAL_INPUT_READ_FAILED: %w", err)
}
defer file.Close()
info, err := file.Stat()
if err != nil {
return "", fmt.Errorf("LOCAL_INPUT_READ_FAILED: %w", err)
}
@@ -80,9 +98,12 @@ func ReadTextInput(spec string, stdin io.Reader, maxBytes int64) (string, error)
if info.Size() > maxBytes {
return "", fmt.Errorf("LOCAL_INPUT_TOO_LARGE: 文件大小 %d 超过 %d 字节", info.Size(), maxBytes)
}
data, err := readTextInputFile(realPath)
data, err := readTextInputAll(io.LimitReader(file, maxBytes+1))
if err != nil {
return "", fmt.Errorf("LOCAL_INPUT_READ_FAILED: %w", err)
}
if int64(len(data)) > maxBytes {
return "", fmt.Errorf("LOCAL_INPUT_TOO_LARGE: 文件超过 %d 字节", maxBytes)
}
return string(data), nil
}
+99
View File
@@ -0,0 +1,99 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package localio
import (
"io"
"os"
"path/filepath"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
func TestCrossPlatformCoverageReadTextInputUsesOpenedDescriptorAndBoundedReadE2E(t *testing.T) {
dir := t.TempDir()
old, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chdir(old) })
t.Run("file grows after stat", func(t *testing.T) {
path := filepath.Join(dir, "grow.txt")
if err := os.WriteFile(path, []byte("ok"), 0o600); err != nil {
t.Fatal(err)
}
testseam.Swap(t, &readTextInputAll, func(reader io.Reader) ([]byte, error) {
file, openErr := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0)
if openErr != nil {
return nil, openErr
}
if _, writeErr := file.WriteString("-too-large"); writeErr != nil {
_ = file.Close()
return nil, writeErr
}
if closeErr := file.Close(); closeErr != nil {
return nil, closeErr
}
return io.ReadAll(reader)
})
if _, err := ReadTextInput("@grow.txt", nil, 4); err == nil {
t.Fatal("file growth bypassed size limit")
}
})
t.Run("path becomes directory before descriptor check", func(t *testing.T) {
path := filepath.Join(dir, "becomes-directory.txt")
if err := os.WriteFile(path, []byte("safe"), 0o600); err != nil {
t.Fatal(err)
}
testseam.Swap(t, &openTextInputFile, func(candidate string) (*os.File, error) {
if removeErr := os.Remove(candidate); removeErr != nil {
return nil, removeErr
}
if mkdirErr := os.Mkdir(candidate, 0o700); mkdirErr != nil {
return nil, mkdirErr
}
return os.Open(candidate)
})
if _, err := ReadTextInput("@becomes-directory.txt", nil, 10); err == nil {
t.Fatal("path type replacement bypassed descriptor validation")
}
})
t.Run("file grows before descriptor check", func(t *testing.T) {
path := filepath.Join(dir, "grows-before-stat.txt")
if err := os.WriteFile(path, []byte("ok"), 0o600); err != nil {
t.Fatal(err)
}
testseam.Swap(t, &openTextInputFile, func(candidate string) (*os.File, error) {
file, openErr := os.Open(candidate)
if openErr != nil {
return nil, openErr
}
writer, writeOpenErr := os.OpenFile(candidate, os.O_APPEND|os.O_WRONLY, 0)
if writeOpenErr != nil {
_ = file.Close()
return nil, writeOpenErr
}
if _, writeErr := writer.WriteString("-too-large"); writeErr != nil {
_ = writer.Close()
_ = file.Close()
return nil, writeErr
}
if closeErr := writer.Close(); closeErr != nil {
_ = file.Close()
return nil, closeErr
}
return file, nil
})
if _, err := ReadTextInput("@grows-before-stat.txt", nil, 4); err == nil {
t.Fatal("pre-open growth bypassed descriptor size validation")
}
})
}
+84
View File
@@ -0,0 +1,84 @@
//go:build unix
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package localio
import (
"os"
"path/filepath"
"strings"
"syscall"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
func TestCrossPlatformCoverageReadTextInputUsesOpenedDescriptorAfterPathReplacementE2E(t *testing.T) {
dir := t.TempDir()
old, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chdir(old) })
path := filepath.Join(dir, "input.txt")
replacement := filepath.Join(dir, "replacement.txt")
if err := os.WriteFile(path, []byte("safe"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(replacement, []byte("replacement-exceeds-limit"), 0o600); err != nil {
t.Fatal(err)
}
testseam.Swap(t, &openTextInputFile, func(candidate string) (*os.File, error) {
file, openErr := os.Open(candidate)
if openErr != nil {
return nil, openErr
}
if renameErr := os.Rename(replacement, candidate); renameErr != nil {
_ = file.Close()
return nil, renameErr
}
return file, nil
})
got, err := ReadTextInput("@input.txt", nil, 10)
if err != nil || got != "safe" {
t.Fatalf("replacement read = %q, %v", got, err)
}
}
func TestCrossPlatformCoverageReadTextInputRejectsUnconnectedFIFOWithoutBlockingE2E(t *testing.T) {
dir := t.TempDir()
old, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Chdir(old) })
fifo := filepath.Join(dir, "input.fifo")
if err := syscall.Mkfifo(fifo, 0o600); err != nil {
t.Fatal(err)
}
result := make(chan error, 1)
go func() {
_, readErr := ReadTextInput("@input.fifo", nil, 1024)
result <- readErr
}()
select {
case err := <-result:
if err == nil || !strings.Contains(err.Error(), "LOCAL_INPUT_INVALID") {
t.Fatalf("FIFO rejection error = %v", err)
}
case <-time.After(time.Second):
t.Fatal("FIFO without a writer blocked text input validation")
}
}
+18 -3
View File
@@ -44,6 +44,9 @@ func TestCrossPlatformCoverageReadTextInputRejectsEscapeAndOversizeE2E(t *testin
t.Fatalf("unsafe input accepted: %q", spec)
}
}
if _, err := ReadTextInput("too-large", nil, 2); err == nil {
t.Fatal("oversize literal accepted")
}
if _, err := ReadTextInput("-", strings.NewReader("too-large"), 2); err == nil {
t.Fatal("oversize stdin accepted")
}
@@ -103,11 +106,23 @@ func TestCrossPlatformCoverageReadTextInputFailureBranchesE2E(t *testing.T) {
"relative": func(t *testing.T) {
testseam.Swap(t, &readTextInputRel, func(string, string) (string, error) { return "", errors.New("rel") })
},
"stat": func(t *testing.T) {
testseam.Swap(t, &readTextInputStat, func(string) (os.FileInfo, error) { return nil, errors.New("stat") })
"path stat": func(t *testing.T) {
testseam.Swap(t, &statTextInputPath, func(string) (os.FileInfo, error) { return nil, errors.New("stat") })
},
"open": func(t *testing.T) {
testseam.Swap(t, &openTextInputFile, func(string) (*os.File, error) { return nil, errors.New("open") })
},
"opened file stat": func(t *testing.T) {
testseam.Swap(t, &openTextInputFile, func(candidate string) (*os.File, error) {
file, openErr := os.Open(candidate)
if openErr == nil {
_ = file.Close()
}
return file, openErr
})
},
"read file": func(t *testing.T) {
testseam.Swap(t, &readTextInputFile, func(string) ([]byte, error) { return nil, errors.New("read file") })
testseam.Swap(t, &readTextInputAll, func(io.Reader) ([]byte, error) { return nil, errors.New("read file") })
},
} {
t.Run(name, func(t *testing.T) {