Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
69911543c3 | ||
|
|
b58b8c51bf | ||
|
|
a7678472ab | ||
|
|
f413db06be | ||
|
|
3d67d83110 | ||
|
|
df088573fb | ||
|
|
a0c64e5ef4 | ||
|
|
7a140e59c3 |
@@ -34,8 +34,8 @@ jobs:
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: read
|
||||
# 该 job 仅处理 PR;评论写入也限定在 PR Conversation 这一权限域。
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Check out default branch tooling
|
||||
uses: actions/checkout@v4
|
||||
@@ -69,12 +69,10 @@ jobs:
|
||||
PARSE_ERROR: ${{ steps.parse.outputs.error }}
|
||||
run: |
|
||||
body="❌ /eval 命令解析失败:${PARSE_ERROR}"
|
||||
jq -n --arg body "$body" '{body: $body}' | curl --fail --silent --show-error \
|
||||
-X POST \
|
||||
-H "Authorization: Bearer ${GH_TOKEN}" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
--data @- \
|
||||
"https://api.github.com/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" > /dev/null
|
||||
gh api --method POST \
|
||||
"repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" \
|
||||
--raw-field body="$body" \
|
||||
> /dev/null
|
||||
exit 1
|
||||
|
||||
- name: Verify reviewed PR head
|
||||
@@ -103,13 +101,9 @@ jobs:
|
||||
set -euo pipefail
|
||||
placeholder_body="🛰️ /eval 请求已通过权限与版本校验,正在生成可验证的评测请求。"
|
||||
response="$(
|
||||
jq -n --arg body "$placeholder_body" '{body: $body}' \
|
||||
| curl --fail --silent --show-error \
|
||||
-X POST \
|
||||
-H "Authorization: Bearer ${GH_TOKEN}" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
--data @- \
|
||||
"https://api.github.com/repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments"
|
||||
gh api --method POST \
|
||||
"repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" \
|
||||
--raw-field body="$placeholder_body"
|
||||
)"
|
||||
comment_id="$(
|
||||
printf '%s' "$response" \
|
||||
@@ -277,13 +271,9 @@ jobs:
|
||||
fi
|
||||
body="<!-- eval-dispatch: ${marker_json} -->"$'\n'"🛰️ /eval 已受理:产品集 \`${PRODUCTS}\`${cases_note},评测对象 \`${PR_HEAD_SHA}\`。"$'\n'"受控评测服务将在数分钟内处理,完成后由 bot 回贴报告。"
|
||||
response="$(
|
||||
jq -n --arg body "$body" '{body: $body}' \
|
||||
| curl --fail --silent --show-error \
|
||||
-X PATCH \
|
||||
-H "Authorization: Bearer ${GH_TOKEN}" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
--data @- \
|
||||
"https://api.github.com/repos/${GITHUB_REPOSITORY}/issues/comments/${DISPATCH_COMMENT_ID}"
|
||||
gh api --method PATCH \
|
||||
"repos/${GITHUB_REPOSITORY}/issues/comments/${DISPATCH_COMMENT_ID}" \
|
||||
--raw-field body="$body"
|
||||
)"
|
||||
printf '%s' "$response" \
|
||||
| jq -e \
|
||||
@@ -299,12 +289,8 @@ jobs:
|
||||
DISPATCH_COMMENT_ID: ${{ steps.placeholder.outputs.comment_id }}
|
||||
run: |
|
||||
failure_body="❌ /eval 请求准备失败,未生成可消费的评测请求。请稍后重试。"
|
||||
jq -n --arg body "$failure_body" '{body: $body}' \
|
||||
| curl --fail --silent --show-error \
|
||||
-X PATCH \
|
||||
-H "Authorization: Bearer ${GH_TOKEN}" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
--data @- \
|
||||
"https://api.github.com/repos/${GITHUB_REPOSITORY}/issues/comments/${DISPATCH_COMMENT_ID}" \
|
||||
gh api --method PATCH \
|
||||
"repos/${GITHUB_REPOSITORY}/issues/comments/${DISPATCH_COMMENT_ID}" \
|
||||
--raw-field body="$failure_body" \
|
||||
> /dev/null \
|
||||
|| true
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -44,6 +44,84 @@ func TestEvalDispatchWorkflowUsesRepositoryPermissionAndReviewedSHA(t *testing.T
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvalDispatchWorkflowCanWritePRConversationComments(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root, err := filepath.Abs(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
t.Fatalf("resolve repository root: %v", err)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(root, ".github", "workflows", "eval-dispatch.yml"))
|
||||
if err != nil {
|
||||
t.Fatalf("read eval-dispatch workflow: %v", err)
|
||||
}
|
||||
workflow := string(data)
|
||||
|
||||
dispatchStart := strings.Index(workflow, "jobs:\n dispatch:")
|
||||
if dispatchStart < 0 {
|
||||
t.Fatal("eval-dispatch workflow missing dispatch job")
|
||||
}
|
||||
stepsOffset := strings.Index(workflow[dispatchStart:], "\n steps:")
|
||||
if stepsOffset < 0 {
|
||||
t.Fatal("eval-dispatch workflow missing dispatch steps")
|
||||
}
|
||||
permissions := workflow[dispatchStart : dispatchStart+stepsOffset]
|
||||
if !containsTrimmedLine(permissions, "pull-requests: write") {
|
||||
t.Error("dispatch job must grant write permission for PR conversation comments")
|
||||
}
|
||||
if containsTrimmedLine(permissions, "pull-requests: read") {
|
||||
t.Error("dispatch job still limits pull requests to read-only")
|
||||
}
|
||||
if containsTrimmedLine(permissions, "issues: write") {
|
||||
t.Error("dispatch job must keep a single write domain instead of granting issue-wide writes")
|
||||
}
|
||||
|
||||
commentWrites := []struct {
|
||||
step string
|
||||
method string
|
||||
endpoint string
|
||||
}{
|
||||
{
|
||||
step: "Reply usage on parse failure",
|
||||
method: "POST",
|
||||
endpoint: "issues/${PR_NUMBER}/comments",
|
||||
},
|
||||
{
|
||||
step: "Create dispatch placeholder",
|
||||
method: "POST",
|
||||
endpoint: "issues/${PR_NUMBER}/comments",
|
||||
},
|
||||
{
|
||||
step: "Finalize dispatch marker",
|
||||
method: "PATCH",
|
||||
endpoint: "issues/comments/${DISPATCH_COMMENT_ID}",
|
||||
},
|
||||
{
|
||||
step: "Mark dispatch preparation failure",
|
||||
method: "PATCH",
|
||||
endpoint: "issues/comments/${DISPATCH_COMMENT_ID}",
|
||||
},
|
||||
}
|
||||
for _, write := range commentWrites {
|
||||
write := write
|
||||
t.Run(write.step, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
step := evalDispatchWorkflowStep(t, workflow, write.step)
|
||||
if !strings.Contains(step, "gh api --method "+write.method) {
|
||||
t.Errorf("step %q must use gh api so GitHub's HTTP error message remains visible", write.step)
|
||||
}
|
||||
if !strings.Contains(step, write.endpoint) {
|
||||
t.Errorf("step %q missing comment endpoint %q", write.step, write.endpoint)
|
||||
}
|
||||
for _, forbidden := range []string{"curl --fail", "Authorization: Bearer", "2>/dev/null"} {
|
||||
if strings.Contains(step, forbidden) {
|
||||
t.Errorf("step %q hides or manually transports API diagnostics via %q", write.step, forbidden)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvalDispatchWorkflowPublishesArtifactBoundRequest(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -102,7 +180,7 @@ func TestEvalDispatchWorkflowPublishesArtifactBoundRequest(t *testing.T) {
|
||||
"ARTIFACT_ID: ${{ steps.artifact.outputs.artifact-id }}",
|
||||
"ARTIFACT_DIGEST: ${{ steps.artifact.outputs.artifact-digest }}",
|
||||
"<!-- eval-dispatch: ${marker_json} -->",
|
||||
"-X PATCH",
|
||||
"gh api --method PATCH",
|
||||
"issues/comments/${DISPATCH_COMMENT_ID}",
|
||||
} {
|
||||
if !strings.Contains(workflow, want) {
|
||||
@@ -120,6 +198,30 @@ func TestEvalDispatchWorkflowPublishesArtifactBoundRequest(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func evalDispatchWorkflowStep(t *testing.T, workflow, name string) string {
|
||||
t.Helper()
|
||||
|
||||
marker := " - name: " + name + "\n"
|
||||
start := strings.Index(workflow, marker)
|
||||
if start < 0 {
|
||||
t.Fatalf("eval-dispatch workflow missing step %q", name)
|
||||
}
|
||||
rest := workflow[start+len(marker):]
|
||||
if end := strings.Index(rest, "\n - name: "); end >= 0 {
|
||||
return rest[:end]
|
||||
}
|
||||
return rest
|
||||
}
|
||||
|
||||
func containsTrimmedLine(text, want string) bool {
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
if strings.TrimSpace(line) == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestEvalPollValidatePython(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user