Compare commits

...

4 Commits

Author SHA1 Message Date
eb76daaaba fix: redact sandbox token and enable CI lint (#104)
All checks were successful
submodules sync / sync (push) Successful in 44s
build / build (push) Successful in 1m47s
build / trigger-build-image (push) Successful in 6s
Reviewed-on: #104
Co-authored-by: Boming Zhang <bomingzh@sjtu.edu.cn>
Co-committed-by: Boming Zhang <bomingzh@sjtu.edu.cn>
2026-08-03 18:31:36 +08:00
1fbc37d669 fix: better stage reliability (#103)
All checks were successful
submodules sync / sync (push) Successful in 59s
build / build (push) Successful in 2m16s
build / trigger-build-image (push) Successful in 6s
Reviewed-on: #103
Co-authored-by: Boming Zhang <bomingzh@sjtu.edu.cn>
Co-committed-by: Boming Zhang <bomingzh@sjtu.edu.cn>
2026-08-03 10:37:40 +08:00
100b04b876
revert: fix: sandbox executor pack size limit (#101)
All checks were successful
submodules sync / sync (push) Successful in 1m2s
build / build (push) Successful in 2m23s
build / trigger-build-image (push) Successful in 7s
This reverts commit 62b1538b6a.
2026-08-01 21:16:25 -07:00
62b1538b6a fix: sandbox executor pack size limit (#101)
All checks were successful
submodules sync / sync (push) Successful in 1m0s
build / build (push) Successful in 1m28s
build / trigger-build-image (push) Successful in 6s
Reviewed-on: #101
Co-authored-by: Boming Zhang <bomingzh@sjtu.edu.cn>
Co-committed-by: Boming Zhang <bomingzh@sjtu.edu.cn>
2026-07-24 13:04:09 +08:00
30 changed files with 1430 additions and 119 deletions

View File

@ -19,8 +19,8 @@ jobs:
echo "${{ secrets.DEPLOY_KEY }}" > ~/.ssh/id_ed25519.pub echo "${{ secrets.DEPLOY_KEY }}" > ~/.ssh/id_ed25519.pub
chmod 600 ~/.ssh/id_ed25519 ~/.ssh/id_ed25519.pub chmod 600 ~/.ssh/id_ed25519 ~/.ssh/id_ed25519.pub
ssh -o StrictHostKeyChecking=accept-new -T git@focs.gc.sjtu.edu.cn -p 2222 ssh -o StrictHostKeyChecking=accept-new -T git@focs.gc.sjtu.edu.cn -p 2222
# - name: Lint - name: Lint
# run: make lint run: make lint
- name: Build - name: Build
run: make build run: make build
- name: Version - name: Version

View File

@ -8,6 +8,7 @@ DATE := $(shell date +"%Y%m%d-%H%M%S")
VERSION := $(COMMIT_HASH)-$(DATE) VERSION := $(COMMIT_HASH)-$(DATE)
LDFLAGS := -s -w -X main.Version=$(VERSION) LDFLAGS := -s -w -X main.Version=$(VERSION)
GOFLAGS := -trimpath -mod=readonly -buildvcs=false GOFLAGS := -trimpath -mod=readonly -buildvcs=false
COVERAGE_FILE ?= coverage.out
all: build all: build
@ -27,18 +28,15 @@ lint:
prepare-test: prepare-test:
git submodule update --init --remote git submodule update --init --remote
test: test: build
./scripts/prepare_test_repos.sh $(TMP_DIR) ./scripts/prepare_test_repos.sh $(TMP_DIR)
# no clang-tidy-18 locally # no clang-tidy-18 locally
rm -rf $(TMP_DIR)/submodules/JOJ3-examples/examples/keyword/clangtidy rm -rf $(TMP_DIR)/submodules/JOJ3-examples/examples/keyword/clangtidy
go test -count=1 -v ./... go test -count=1 -v -coverpkg=./... -coverprofile=$(COVERAGE_FILE) ./...
go tool cover -func=$(COVERAGE_FILE) | tail -n 1
local-test:
rm -rf $(TMP_DIR)/submodules/JOJ3-examples/examples/
mkdir -p $(TMP_DIR)/submodules/JOJ3-examples/examples/
go test -count=1 -v ./...
ci-test: ci-test:
./scripts/prepare_test_repos.sh $(TMP_DIR) ./scripts/prepare_test_repos.sh $(TMP_DIR)
./scripts/run_foreach_test_repos.sh $(TMP_DIR) "sed -i '2i \ \ \"sandboxExecServer\": \"172.17.0.1:5051\",' conf.json" ./scripts/run_foreach_test_repos.sh $(TMP_DIR) "sed -i '2i \ \ \"sandboxExecServer\": \"172.17.0.1:5051\",' conf.json"
GITHUB_ACTOR="" go test -count=1 -v ./... GITHUB_ACTOR="" go test -count=1 -v -coverpkg=./... -coverprofile=$(COVERAGE_FILE) ./...
go tool cover -func=$(COVERAGE_FILE) | tail -n 1

View File

@ -1,6 +1,6 @@
# JOJ3 # JOJ3
[![Go Report Card](https://goreportcard.com/badge/github.com/joint-online-judge/JOJ3)](https://goreportcard.com/report/github.com/joint-online-judge/JOJ3) [![Build](https://focs.gc.sjtu.edu.cn/git/JOJ/JOJ3/actions/workflows/build.yaml/badge.svg?branch=master)](https://focs.gc.sjtu.edu.cn/git/JOJ/JOJ3/actions?workflow=build.yaml)
[![Go Reference](https://pkg.go.dev/badge/github.com/joint-online-judge/JOJ3.svg)](https://pkg.go.dev/github.com/joint-online-judge/JOJ3) [![Go Reference](https://pkg.go.dev/badge/github.com/joint-online-judge/JOJ3.svg)](https://pkg.go.dev/github.com/joint-online-judge/JOJ3)
[![DeepWiki](https://img.shields.io/badge/DeepWiki-joint--online--judge%2FJOJ3-blue.svg)](https://deepwiki.com/joint-online-judge/JOJ3) [![DeepWiki](https://img.shields.io/badge/DeepWiki-joint--online--judge%2FJOJ3-blue.svg)](https://deepwiki.com/joint-online-judge/JOJ3)
@ -100,6 +100,7 @@ Here are the steps `joj3` will run.
3. Generate stages. 3. Generate stages.
- We have an empty list of stages at the beginning. - We have an empty list of stages at the beginning.
- We check all the stages from the configuration file. Stages with empty `group` field will always be added. Stages with non-empty `group` field requires that value (case insensitive) appears in the commit group. e.g. with commit msg `feat(h5/e3): joj msan [joj]`, stages with the following `group` field will run: `""`, `"joj"`. Currently, it does not support multiple groups within one commit. If the group specified in the commit message is `[all]`, then all groups will run. - We check all the stages from the configuration file. Stages with empty `group` field will always be added. Stages with non-empty `group` field requires that value (case insensitive) appears in the commit group. e.g. with commit msg `feat(h5/e3): joj msan [joj]`, stages with the following `group` field will run: `""`, `"joj"`. Currently, it does not support multiple groups within one commit. If the group specified in the commit message is `[all]`, then all groups will run.
- Groups are matched as case-insensitive, comma/space/semicolon/pipe-separated tokens. For example, `[joj, lint]` selects the `joj` and `lint` groups without substring matching.
- Every stage needs to have an unique `name`, which means if two stages have the same name, only the first one will be added. - Every stage needs to have an unique `name`, which means if two stages have the same name, only the first one will be added.
4. Run stages. 4. Run stages.
- By default, all the stages will run sequentially. - By default, all the stages will run sequentially.

View File

@ -175,6 +175,9 @@ func GetConfPath(confRoot, confName, fallbackConfName, msg, tag string) (
hintValidScopes(confRoot, confName) hintValidScopes(confRoot, confName)
} }
slog.Error("stat conf", "error", err) slog.Error("stat conf", "error", err)
if tag != "" {
return confPath, confStat, conventionalCommit, err
}
// fallback to conf file in conf root on conf not exist // fallback to conf file in conf root on conf not exist
confPath = filepath.Join(confRoot, fallbackConfName) confPath = filepath.Join(confRoot, fallbackConfName)
slog.Info("fallback to conf", "path", confPath) slog.Info("fallback to conf", "path", confPath)
@ -201,8 +204,13 @@ func GetConfPath(confRoot, confName, fallbackConfName, msg, tag string) (
func MatchGroups(conf *Conf, conventionalCommit *ConventionalCommit) []string { func MatchGroups(conf *Conf, conventionalCommit *ConventionalCommit) []string {
seen := make(map[string]bool) seen := make(map[string]bool)
keywords := []string{} keywords := []string{}
loweredCommitGroup := strings.ToLower(conventionalCommit.Group) requestedGroups := make(map[string]bool)
matchAllGroups := loweredCommitGroup == "all" for _, group := range strings.FieldsFunc(conventionalCommit.Group, func(r rune) bool {
return r == ',' || r == ';' || r == '|' || r == ' ' || r == '\t'
}) {
requestedGroups[strings.ToLower(group)] = true
}
matchAllGroups := requestedGroups["all"]
confStages := []ConfStage{} confStages := []ConfStage{}
confStages = append(confStages, conf.PreStages...) confStages = append(confStages, conf.PreStages...)
confStages = append(confStages, conf.Stages...) confStages = append(confStages, conf.Stages...)
@ -221,7 +229,7 @@ func MatchGroups(conf *Conf, conventionalCommit *ConventionalCommit) []string {
slog.Info("group keywords from stages", "keywords", keywords) slog.Info("group keywords from stages", "keywords", keywords)
groups := []string{} groups := []string{}
for _, keyword := range keywords { for _, keyword := range keywords {
if matchAllGroups || strings.Contains(loweredCommitGroup, keyword) { if matchAllGroups || requestedGroups[keyword] {
groups = append(groups, keyword) groups = append(groups, keyword)
} }
} }

View File

@ -1,10 +1,89 @@
package conf package conf
import ( import (
"bytes"
"crypto/sha256"
"encoding/hex"
"log/slog"
"os"
"path/filepath"
"reflect" "reflect"
"strings"
"testing" "testing"
) )
func TestConfLogValueRedactsSandboxToken(t *testing.T) {
var output bytes.Buffer
logger := slog.New(slog.NewJSONHandler(&output, nil))
conf := &Conf{
Name: "test",
SandboxToken: "top-secret-token",
}
logger.Info("config", "conf", conf)
got := output.String()
if strings.Contains(got, "top-secret-token") {
t.Fatalf("configuration log exposed sandbox token: %s", got)
}
if !strings.Contains(got, "[REDACTED]") || !strings.Contains(got, `"Name":"test"`) {
t.Fatalf("configuration log lost expected diagnostic fields: %s", got)
}
if conf.SandboxToken != "top-secret-token" {
t.Fatalf("logging mutated the runtime configuration: %q", conf.SandboxToken)
}
}
func TestGetSHA256(t *testing.T) {
path := filepath.Join(t.TempDir(), "input")
content := []byte("joj3")
if err := os.WriteFile(path, content, 0o600); err != nil {
t.Fatal(err)
}
wantSum := sha256.Sum256(content)
got, err := GetSHA256(path)
if err != nil || got != hex.EncodeToString(wantSum[:]) {
t.Fatalf("GetSHA256() = %q, %v", got, err)
}
if _, err := GetSHA256(path + ".missing"); !os.IsNotExist(err) {
t.Fatalf("missing GetSHA256() error = %v", err)
}
}
func TestGetConfPath(t *testing.T) {
root := t.TempDir()
scopedDir := filepath.Join(root, "course")
if err := os.Mkdir(scopedDir, 0o700); err != nil {
t.Fatal(err)
}
for _, path := range []string{filepath.Join(scopedDir, "conf.json"), filepath.Join(root, "fallback.json")} {
if err := os.WriteFile(path, []byte("{}"), 0o600); err != nil {
t.Fatal(err)
}
}
got, _, commit, err := GetConfPath(root, "conf.json", "fallback.json", "test(course): run", "")
if err != nil || got != filepath.Join(scopedDir, "conf.json") || commit.Scope != "course" {
t.Fatalf("GetConfPath(scoped) = %q, %+v, %v", got, commit, err)
}
got, _, _, err = GetConfPath(root, "conf.json", "fallback.json", "not conventional", "")
if err != nil || got != filepath.Join(root, "fallback.json") {
t.Fatalf("GetConfPath(fallback) = %q, %v", got, err)
}
if _, _, err = parseMsg(root, "conf.json", "test(../escape): run", ""); err == nil || !strings.Contains(err.Error(), "invalid scope") {
t.Fatalf("parseMsg(traversal) error = %v", err)
}
got, _, commit, err = GetConfPath(root, "conf.json", "fallback.json", "ignored", "missing-tag")
if !os.IsNotExist(err) || got != filepath.Join(root, "missing-tag", "conf.json") || commit.Scope != "missing-tag" {
t.Fatalf("GetConfPath(tag) = %q, %+v, %v", got, commit, err)
}
if err := os.Remove(filepath.Join(root, "fallback.json")); err != nil {
t.Fatal(err)
}
got, _, _, err = GetConfPath(root, "conf.json", "fallback.json", "invalid", "")
if !os.IsNotExist(err) || got != filepath.Join(root, "fallback.json") {
t.Fatalf("GetConfPath(missing fallback) = %q, %v", got, err)
}
}
func TestParseConventionalCommit(t *testing.T) { func TestParseConventionalCommit(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@ -130,3 +209,34 @@ func TestParseConventionalCommit(t *testing.T) {
}) })
} }
} }
func TestMatchGroupsUsesExactTokens(t *testing.T) {
tests := []struct {
name string
group string
want []string
}{
{name: "exact not substring", group: "cpp", want: []string{"cpp"}},
{name: "case insensitive", group: "CPP", want: []string{"cpp"}},
{name: "comma and space", group: "cpp, lint", want: []string{"cpp", "lint"}},
{name: "semicolon", group: "cpp;lint", want: []string{"cpp", "lint"}},
{name: "pipe", group: "cpp|lint", want: []string{"cpp", "lint"}},
{name: "tab", group: "cpp\tlint", want: []string{"cpp", "lint"}},
{name: "duplicate token", group: "cpp,cpp", want: []string{"cpp"}},
{name: "all", group: "ALL", want: []string{"c", "cpp", "lint"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
conf := &Conf{Stages: []ConfStage{
{Name: "short", Groups: []string{"c"}},
{Name: "cpp", Groups: []string{"cpp"}},
{Name: "lint", Groups: []string{"lint"}},
}}
got := MatchGroups(conf, &ConventionalCommit{Group: tt.group})
if !reflect.DeepEqual(got, tt.want) {
t.Fatalf("MatchGroups() = %v, want %v", got, tt.want)
}
})
}
}

View File

@ -1,6 +1,8 @@
package conf package conf
import ( import (
"log/slog"
"github.com/joint-online-judge/JOJ3/internal/stage" "github.com/joint-online-judge/JOJ3/internal/stage"
) )
@ -32,6 +34,16 @@ type Conf struct {
PostStages []ConfStage PostStages []ConfStage
} }
// LogValue preserves the configuration's diagnostic value without writing the
// sandbox credential to text or structured logs.
func (c Conf) LogValue() slog.Value {
type logConf Conf
if c.SandboxToken != "" {
c.SandboxToken = "[REDACTED]"
}
return slog.AnyValue(logConf(c))
}
type OptionalCmd struct { type OptionalCmd struct {
Args *[]string Args *[]string
Env *[]string Env *[]string

View File

@ -104,6 +104,7 @@ func run(conf *joj3Conf.Conf, conventionalCommit *joj3Conf.ConventionalCommit) e
) )
if err != nil { if err != nil {
slog.Error("stage run", "error", err) slog.Error("stage run", "error", err)
return err
} }
if forceQuitStageName != "" { if forceQuitStageName != "" {
slog.Info("stage force quit", "name", forceQuitStageName) slog.Info("stage force quit", "name", forceQuitStageName)

View File

@ -87,7 +87,16 @@ func TestRun(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
for _, tt := range tests { for _, tt := range tests {
// The repo-health-checker package runs all healthcheck fixtures directly so
// their execution contributes to Go coverage. Keep one sandbox case here as
// an end-to-end binary/executor/parser smoke test.
if strings.HasPrefix(tt, "healthcheck/") && tt != "healthcheck/release" {
continue
}
t.Run(tt, func(t *testing.T) { t.Run(tt, func(t *testing.T) {
if tt == "healthcheck/release" {
prepareLargeCopyInFixture(t, filepath.Join(root, tt))
}
t.Chdir(fmt.Sprintf("%s%s", root, tt)) t.Chdir(fmt.Sprintf("%s%s", root, tt))
os.Args = []string{"./joj3"} os.Args = []string{"./joj3"}
outputFile := "joj3_result.json" outputFile := "joj3_result.json"
@ -106,3 +115,54 @@ func TestRun(t *testing.T) {
}) })
} }
} }
func prepareLargeCopyInFixture(t *testing.T, dir string) {
t.Helper()
const fileCount = 1001
filesDir := filepath.Join(dir, "many-files")
if err := os.Mkdir(filesDir, 0o700); err != nil && !os.IsExist(err) {
t.Fatal(err)
}
for i := range fileCount {
path := filepath.Join(filesDir, fmt.Sprintf("%04d", i))
if err := os.WriteFile(path, nil, 0o600); err != nil {
t.Fatal(err)
}
}
t.Cleanup(func() { _ = os.RemoveAll(filesDir) })
confPath := filepath.Join(dir, "conf.json")
original, err := os.ReadFile(confPath)
if err != nil {
t.Fatal(err)
}
var conf map[string]any
if err := json.Unmarshal(original, &conf); err != nil {
t.Fatal(err)
}
stages := conf["stages"].([]any)
executor := stages[0].(map[string]any)["executor"].(map[string]any)
with := executor["with"].(map[string]any)
command := with["default"].(map[string]any)
args := command["args"].([]any)
checkerArgs := make([]string, 0, len(args))
for _, arg := range args {
checkerArgs = append(checkerArgs, fmt.Sprintf("%q", arg))
}
command["args"] = []string{
"/bin/sh", "-c",
fmt.Sprintf("test \"$(find many-files -type f | wc -l)\" -eq %d && exec %s", fileCount, strings.Join(checkerArgs, " ")),
}
patched, err := json.Marshal(conf)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(confPath, patched, 0o600); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := os.WriteFile(confPath, original, 0o600); err != nil {
t.Errorf("restore conf: %v", err)
}
})
}

View File

@ -1,7 +1,9 @@
package main package main
import ( import (
"context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"log/slog" "log/slog"
"os" "os"
@ -124,6 +126,7 @@ func newErrorStageResults(err error) ([]stage.StageResult, string) {
}, "Internal Error" }, "Internal Error"
} }
//nolint:unparam // named err lets deferred cleanup errors reach the caller
func runStages( func runStages(
conf *conf.Conf, conf *conf.Conf,
groups []string, groups []string,
@ -153,36 +156,42 @@ func runStages(
stageResults, forceQuitStageName = newErrorStageResults(err) stageResults, forceQuitStageName = newErrorStageResults(err)
return stageResults, forceQuitStageName, err return stageResults, forceQuitStageName, err
} }
defer stage.Cleanup() ctx := context.Background()
defer func() {
err = errors.Join(err, stage.Cleanup(ctx))
}()
// ignore force quit in preStages & postStages // ignore force quit in preStages & postStages
slog.Info("run preStages") slog.Info("run preStages")
_, _, err = stage.Run(preStages) _, _, preErr := stage.Run(ctx, preStages)
if err != nil { if preErr != nil {
slog.Error("run preStages", "error", err) slog.Error("run preStages", "error", preErr)
} }
slog.Info("run stages") slog.Info("run stages")
stageResults, forceQuitStageName, err = stage.Run(stages) stageResults, forceQuitStageName, mainErr := stage.Run(ctx, stages)
if err != nil { if mainErr != nil {
slog.Error("run stages", "error", err) slog.Error("run stages", "error", mainErr)
stageResults, forceQuitStageName = newErrorStageResults(err) stageResults, forceQuitStageName = newErrorStageResults(mainErr)
} }
onStagesComplete(stageResults, forceQuitStageName) onStagesComplete(stageResults, forceQuitStageName)
slog.Info("output result start", "path", conf.OutputPath) slog.Info("output result start", "path", conf.OutputPath)
slog.Debug("output result start", slog.Debug("output result start",
"path", conf.OutputPath, "results", stageResults) "path", conf.OutputPath, "results", stageResults)
content, err := json.Marshal(stageResults) content, marshalErr := json.Marshal(stageResults)
if err != nil { if marshalErr != nil {
slog.Error("marshal stageResults", "error", err) slog.Error("marshal stageResults", "error", marshalErr)
}
var outputErr error
if marshalErr == nil {
outputErr = os.WriteFile(conf.OutputPath,
append(content, '\n'), 0o600)
if outputErr != nil {
slog.Error("write stageResults", "error", outputErr)
} }
err = os.WriteFile(conf.OutputPath,
append(content, []byte("\n")...), 0o600)
if err != nil {
slog.Error("write stageResults", "error", err)
} }
slog.Info("run postStages") slog.Info("run postStages")
_, _, err = stage.Run(postStages) _, _, postErr := stage.Run(ctx, postStages)
if err != nil { if postErr != nil {
slog.Error("run postStages", "error", err) slog.Error("run postStages", "error", postErr)
} }
return stageResults, forceQuitStageName, err return stageResults, forceQuitStageName, errors.Join(preErr, mainErr, marshalErr, outputErr, postErr)
} }

71
cmd/joj3/stage_test.go Normal file
View File

@ -0,0 +1,71 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/joint-online-judge/JOJ3/cmd/joj3/conf"
"github.com/joint-online-judge/JOJ3/internal/stage"
)
func TestRunStagesPreservesMainAndOutputErrors(t *testing.T) {
brokenStage := conf.ConfStage{Name: "broken"}
brokenStage.Executor.Name = "missing-executor"
c := &conf.Conf{
SandboxExecServer: "localhost:5051",
OutputPath: t.TempDir(),
Stages: []conf.ConfStage{brokenStage},
}
_, _, err := runStages(c, nil, func([]stage.StageResult, string) {})
if err == nil {
t.Fatal("runStages() unexpectedly succeeded")
}
if !strings.Contains(err.Error(), "executor not found") ||
!strings.Contains(err.Error(), "is a directory") {
t.Fatalf("runStages() error = %v, want main and output errors", err)
}
}
func TestRunStagesPreservesPhaseErrorsAndWritesMainFailure(t *testing.T) {
broken := func(name, executor string) conf.ConfStage {
s := conf.ConfStage{Name: name}
s.Executor.Name = executor
return s
}
outputPath := filepath.Join(t.TempDir(), "result.json")
c := &conf.Conf{
SandboxExecServer: "localhost:5051",
OutputPath: outputPath,
PreStages: []conf.ConfStage{broken("pre", "missing-pre")},
Stages: []conf.ConfStage{broken("main", "missing-main")},
PostStages: []conf.ConfStage{broken("post", "missing-post")},
}
callbackCalled := false
_, forceQuit, err := runStages(c, nil, func(results []stage.StageResult, forceQuit string) {
callbackCalled = true
if len(results) != 1 || results[0].Name != "Internal Error" || forceQuit != "Internal Error" {
t.Fatalf("callback results = %v, forceQuit = %q", results, forceQuit)
}
})
if err == nil {
t.Fatal("runStages() unexpectedly succeeded")
}
for _, want := range []string{"missing-pre", "missing-main", "missing-post"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("runStages() error %q does not contain %q", err, want)
}
}
if forceQuit != "Internal Error" || !callbackCalled {
t.Fatalf("forceQuit = %q, callbackCalled = %v", forceQuit, callbackCalled)
}
content, readErr := os.ReadFile(outputPath)
if readErr != nil {
t.Fatal(readErr)
}
if !strings.Contains(string(content), "Internal Error") ||
!strings.Contains(string(content), "missing-main") {
t.Fatalf("output = %s", content)
}
}

View File

@ -7,6 +7,7 @@ import (
"encoding/json" "encoding/json"
"flag" "flag"
"fmt" "fmt"
"io"
"log/slog" "log/slog"
"os" "os"
@ -15,8 +16,8 @@ import (
// parseMultiValueFlag parses a multi-value command-line flag and appends its values to the provided slice. // parseMultiValueFlag parses a multi-value command-line flag and appends its values to the provided slice.
// It registers a flag with the specified name and description, associating it with a multiStringValue receiver. // It registers a flag with the specified name and description, associating it with a multiStringValue receiver.
func parseMultiValueFlag(values *[]string, flagName, description string) { func parseMultiValueFlag(flags *flag.FlagSet, values *[]string, flagName, description string) {
flag.Var((*multiStringValue)(values), flagName, description) flags.Var((*multiStringValue)(values), flagName, description)
} }
type multiStringValue []string type multiStringValue []string
@ -39,7 +40,12 @@ func setupSlog() {
slog.SetDefault(logger) slog.SetDefault(logger)
} }
var ( var Version string
func run(args []string, stdout io.Writer) error {
flags := flag.NewFlagSet("repo-health-checker", flag.ContinueOnError)
flags.SetOutput(io.Discard)
var (
rootDir string rootDir string
repoSize float64 repoSize float64
checkFileNameList string checkFileNameList string
@ -48,27 +54,23 @@ var (
whitelistedChars string whitelistedChars string
allowedDomainList string allowedDomainList string
actorCsvPath string actorCsvPath string
showVersion *bool showVersion bool
Version string )
) flags.BoolVar(&showVersion, "version", false, "print current version")
flags.StringVar(&rootDir, "root", ".", "root dir for forbidden files check")
func init() { flags.Float64Var(&repoSize, "repoSize", 2, "maximum size of the repo in MiB")
showVersion = flag.Bool("version", false, "print current version") flags.StringVar(&checkFileNameList, "checkFileNameList", "", "comma-separated list of files to check")
flag.StringVar(&rootDir, "root", ".", "root dir for forbidden files check") flags.StringVar(&checkFileSumList, "checkFileSumList", "", "comma-separated list of expected checksums")
flag.Float64Var(&repoSize, "repoSize", 2, "maximum size of the repo in MiB") flags.StringVar(&whitelistedChars, "whitelistedChars", "", "comma-separated list of non-ASCII characters allowed in files")
flag.StringVar(&checkFileNameList, "checkFileNameList", "", "comma-separated list of files to check") flags.StringVar(&allowedDomainList, "allowedDomainList", "sjtu.edu.cn", "comma-separated list of allowed domains for commit author email")
flag.StringVar(&checkFileSumList, "checkFileSumList", "", "comma-separated list of expected checksums") flags.StringVar(&actorCsvPath, "actorCsvPath", "/home/tt/.config/joj/students.csv", "path to actor csv file")
flag.StringVar(&whitelistedChars, "whitelistedChars", "", "comma-separated list of non-ASCII characters allowed in files") parseMultiValueFlag(flags, &metaFile, "meta", "meta files to check")
flag.StringVar(&allowedDomainList, "allowedDomainList", "sjtu.edu.cn", "comma-separated list of allowed domains for commit author email") if err := flags.Parse(args); err != nil {
flag.StringVar(&actorCsvPath, "actorCsvPath", "/home/tt/.config/joj/students.csv", "path to actor csv file") return err
parseMultiValueFlag(&metaFile, "meta", "meta files to check") }
} if showVersion {
_, err := fmt.Fprintln(stdout, Version)
func main() { return err
flag.Parse()
if *showVersion {
fmt.Println(Version)
return
} }
setupSlog() setupSlog()
slog.Info("start repo-health-checker", "version", Version) slog.Info("start repo-health-checker", "version", Version)
@ -92,7 +94,15 @@ func main() {
jsonRes, err := json.Marshal(res) jsonRes, err := json.Marshal(res)
if err != nil { if err != nil {
slog.Error("marshal result", "error", err) slog.Error("marshal result", "error", err)
return err
}
_, err = fmt.Fprintln(stdout, string(jsonRes))
return err
}
func main() {
if err := run(os.Args[1:], os.Stdout); err != nil {
slog.Error("repo-health-checker", "error", err)
os.Exit(1) os.Exit(1)
} }
fmt.Println(string(jsonRes))
} }

View File

@ -0,0 +1,101 @@
package main
import (
"bytes"
"encoding/json"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"github.com/joint-online-judge/JOJ3/internal/stage"
"github.com/joint-online-judge/JOJ3/pkg/healthcheck"
)
type exampleConf struct {
Stages []struct {
Executor struct {
With struct {
Default struct {
Args []string `json:"args"`
} `json:"default"`
} `json:"with"`
} `json:"executor"`
} `json:"stages"`
}
func readJSON[T any](t *testing.T, path string) T {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
var value T
if err := json.Unmarshal(data, &value); err != nil {
t.Fatal(err)
}
return value
}
func TestHealthcheckExamples(t *testing.T) {
root := "../../tmp/submodules/JOJ3-examples/examples/healthcheck"
entries, err := os.ReadDir(root)
if err != nil {
t.Fatal(err)
}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
// This fixture is retained in cmd/joj3 as the sandbox binary/parser smoke
// test, so do not execute it a second time here.
if entry.Name() == "release" {
continue
}
t.Run(entry.Name(), func(t *testing.T) {
dir := filepath.Join(root, entry.Name())
conf := readJSON[exampleConf](t, filepath.Join(dir, "conf.json"))
expected := readJSON[[]stage.StageResult](t, filepath.Join(dir, "expected.json"))
if len(conf.Stages) != 1 || len(expected) != 1 || len(expected[0].Results) != 1 {
t.Fatal("healthcheck fixture must contain one stage and one result")
}
args := conf.Stages[0].Executor.With.Default.Args
if len(args) == 0 {
t.Fatal("healthcheck fixture has no command")
}
t.Chdir(dir)
var stdout bytes.Buffer
if err := run(args[1:], &stdout); err != nil {
t.Fatal(err)
}
var got healthcheck.Result
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("decode output %q: %v", stdout.String(), err)
}
want := healthcheck.Result{
Msg: expected[0].Results[0].Comment,
Failed: expected[0].ForceQuit,
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("run() = %+v, want %+v", got, want)
}
})
}
}
func TestRunVersionAndInvalidFlag(t *testing.T) {
oldVersion := Version
Version = "test-version"
t.Cleanup(func() { Version = oldVersion })
var stdout bytes.Buffer
if err := run([]string{"-version"}, &stdout); err != nil {
t.Fatal(err)
}
if strings.TrimSpace(stdout.String()) != Version {
t.Fatalf("version output = %q", stdout.String())
}
if err := run([]string{"-unknown"}, &stdout); err == nil {
t.Fatal("run() accepted an unknown flag")
}
}

View File

@ -1,8 +1,12 @@
package dummy package dummy
import "github.com/joint-online-judge/JOJ3/internal/stage" import (
"context"
func (e *Dummy) Run(cmds []stage.Cmd) ([]stage.ExecutorResult, error) { "github.com/joint-online-judge/JOJ3/internal/stage"
)
func (e *Dummy) Run(_ context.Context, cmds []stage.Cmd) ([]stage.ExecutorResult, error) {
res := make([]stage.ExecutorResult, 0, len(cmds)) res := make([]stage.ExecutorResult, 0, len(cmds))
for range cmds { for range cmds {
res = append(res, stage.ExecutorResult{ res = append(res, stage.ExecutorResult{
@ -19,6 +23,6 @@ func (e *Dummy) Run(cmds []stage.Cmd) ([]stage.ExecutorResult, error) {
return res, nil return res, nil
} }
func (e *Dummy) Cleanup() error { func (e *Dummy) Cleanup(_ context.Context) error {
return nil return nil
} }

View File

@ -2,6 +2,7 @@ package local
import ( import (
"bytes" "bytes"
"context"
"fmt" "fmt"
"io" "io"
"math" "math"
@ -70,14 +71,12 @@ func (e *Local) generateResult(
if err != nil { if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok { if exitErr, ok := err.(*exec.ExitError); ok {
result.Status = stage.StatusNonzeroExitStatus
status := exitErr.Sys().(syscall.WaitStatus) status := exitErr.Sys().(syscall.WaitStatus)
if status.Signaled() { if status.Signaled() {
signal := status.Signal() signal := status.Signal()
switch signal { if signal == syscall.SIGXCPU {
case syscall.SIGXCPU:
result.Status = stage.StatusTimeLimitExceeded result.Status = stage.StatusTimeLimitExceeded
default:
result.Status = stage.StatusNonzeroExitStatus
} }
} }
result.Error = exitErr.Error() result.Error = exitErr.Error()
@ -105,10 +104,16 @@ func (e *Local) generateResult(
return result return result
} }
func (e *Local) Run(cmds []stage.Cmd) ([]stage.ExecutorResult, error) { func (e *Local) Run(ctx context.Context, cmds []stage.Cmd) ([]stage.ExecutorResult, error) {
var results []stage.ExecutorResult var results []stage.ExecutorResult
for _, cmd := range cmds { for _, cmd := range cmds {
if len(cmd.Args) == 0 {
return nil, fmt.Errorf("command args must not be empty")
}
if err := ctx.Err(); err != nil {
return nil, err
}
execCmd := exec.Command(cmd.Args[0], cmd.Args[1:]...) // #nosec G204 execCmd := exec.Command(cmd.Args[0], cmd.Args[1:]...) // #nosec G204
execCmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} execCmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
if cmd.CPULimit > 0 && cmd.ClockLimit <= 0 { if cmd.CPULimit > 0 && cmd.ClockLimit <= 0 {
@ -167,6 +172,10 @@ func (e *Local) Run(cmds []stage.Cmd) ([]stage.ExecutorResult, error) {
false, false,
) )
results = append(results, result) results = append(results, result)
case <-ctx.Done():
_ = syscall.Kill(-execCmd.Process.Pid, syscall.SIGKILL)
<-done
return nil, ctx.Err()
case <-time.After(duration): case <-time.After(duration):
_ = syscall.Kill(-execCmd.Process.Pid, syscall.SIGKILL) _ = syscall.Kill(-execCmd.Process.Pid, syscall.SIGKILL)
err := <-done err := <-done
@ -216,6 +225,6 @@ func handleCopyOut(result *stage.ExecutorResult, cmd stage.Cmd) error {
return nil return nil
} }
func (e *Local) Cleanup() error { func (e *Local) Cleanup(_ context.Context) error {
return nil return nil
} }

View File

@ -0,0 +1,156 @@
package local
import (
"bytes"
"context"
"errors"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"github.com/joint-online-judge/JOJ3/internal/stage"
)
func stringPtr(s string) *string { return &s }
func TestRunCapturesIOAndCopyOut(t *testing.T) {
dir := t.TempDir()
output := filepath.Join(dir, "artifact")
results, err := (&Local{}).Run(context.Background(), []stage.Cmd{{
Args: []string{"/bin/sh", "-c", "read value; printf '%s:%s' \"$MARK\" \"$value\"; printf artifact > \"$1\"", "sh", output},
Env: []string{"MARK=env"},
Stdin: &stage.CmdFile{Content: stringPtr("input\n")},
Stdout: &stage.CmdFile{Name: stringPtr("stdout")},
CopyOut: []string{output, filepath.Join(dir, "optional?")},
}})
if err != nil {
t.Fatal(err)
}
if len(results) != 1 || results[0].Status != stage.StatusAccepted {
t.Fatalf("Run() results = %+v", results)
}
if got := results[0].Files["stdout"]; got != "env:input" {
t.Fatalf("stdout = %q", got)
}
if got := results[0].Files[output]; got != "artifact" {
t.Fatalf("copy-out = %q", got)
}
}
func TestGenerateResultClassifiesErrors(t *testing.T) {
result := (&Local{}).generateResult(errors.New("start failed"), nil, -time.Second,
stage.Cmd{}, bytes.Buffer{}, bytes.Buffer{}, false)
if result.Status != stage.StatusInternalError || result.ExitStatus != -1 || result.RunTime != 0 {
t.Fatalf("generateResult() = %+v", result)
}
cmd := exec.Command("/bin/sh", "-c", "exit 7")
err := cmd.Run()
result = (&Local{}).generateResult(err, cmd.ProcessState, time.Millisecond,
stage.Cmd{}, bytes.Buffer{}, bytes.Buffer{}, false)
if result.Status != stage.StatusNonzeroExitStatus || result.ExitStatus != 7 {
t.Fatalf("exit result = %+v", result)
}
}
func TestRunReportsRequiredCopyOutError(t *testing.T) {
missing := filepath.Join(t.TempDir(), "missing")
results, err := (&Local{}).Run(context.Background(), []stage.Cmd{{
Args: []string{"/bin/true"}, CopyOut: []string{missing},
}})
if err != nil {
t.Fatal(err)
}
if len(results) != 1 || results[0].Status != stage.StatusFileError || results[0].Error == "" {
t.Fatalf("Run() results = %+v", results)
}
}
func TestRunReadsStdinFileAndRunsMultipleCommands(t *testing.T) {
input := filepath.Join(t.TempDir(), "stdin")
if err := os.WriteFile(input, []byte("from-file"), 0o600); err != nil {
t.Fatal(err)
}
results, err := (&Local{}).Run(context.Background(), []stage.Cmd{
{Args: []string{"/bin/true"}},
{
Args: []string{"/bin/cat"},
Stdin: &stage.CmdFile{Src: &input},
Stdout: &stage.CmdFile{Name: stringPtr("stdout")},
},
})
if err != nil {
t.Fatal(err)
}
if len(results) != 2 || results[1].Files["stdout"] != "from-file" {
t.Fatalf("Run() results = %+v", results)
}
}
func TestRunSetupErrors(t *testing.T) {
_, err := (&Local{}).Run(context.Background(), []stage.Cmd{{
Args: []string{"/bin/cat"}, Stdin: &stage.CmdFile{Src: stringPtr(filepath.Join(t.TempDir(), "missing"))},
}})
if err == nil || !strings.Contains(err.Error(), "failed to open stdin file") {
t.Fatalf("stdin error = %v", err)
}
_, err = (&Local{}).Run(context.Background(), []stage.Cmd{{Args: []string{filepath.Join(t.TempDir(), "missing")}}})
if err == nil || !strings.Contains(err.Error(), "failed to start command") {
t.Fatalf("start error = %v", err)
}
}
func TestRunClockTimeout(t *testing.T) {
results, err := (&Local{}).Run(context.Background(), []stage.Cmd{{
Args: []string{"/bin/sleep", "1"}, ClockLimit: uint64(20 * time.Millisecond),
}})
if err != nil {
t.Fatal(err)
}
if len(results) != 1 || results[0].Status != stage.StatusTimeLimitExceeded {
t.Fatalf("Run() results = %+v", results)
}
}
func TestRunRejectsEmptyArgs(t *testing.T) {
_, err := (&Local{}).Run(context.Background(), []stage.Cmd{{}})
if err == nil || err.Error() != "command args must not be empty" {
t.Fatalf("Run() error = %v", err)
}
}
func TestRunCancellationKillsProcessGroup(t *testing.T) {
dir := t.TempDir()
ready := filepath.Join(dir, "ready")
marker := filepath.Join(dir, "marker")
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() {
_, err := (&Local{}).Run(ctx, []stage.Cmd{{
Args: []string{"/bin/sh", "-c", "touch \"$1\"; (sleep 0.3; touch \"$2\") & wait", "sh", ready, marker},
}})
done <- err
}()
deadline := time.Now().Add(2 * time.Second)
for {
if _, err := os.Stat(ready); err == nil {
break
}
if time.Now().After(deadline) {
t.Fatal("command did not start")
}
time.Sleep(10 * time.Millisecond)
}
cancel()
if err := <-done; !errors.Is(err, context.Canceled) {
t.Fatalf("Run() error = %v, want context canceled", err)
}
time.Sleep(500 * time.Millisecond)
if _, err := os.Stat(marker); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("child process survived cancellation: %v", err)
}
}

View File

@ -1,7 +1,7 @@
package sandbox package sandbox
import ( import (
"log/slog" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
@ -12,14 +12,22 @@ import (
) )
// copied from https://github.com/criyle/go-judge/blob/master/cmd/go-judge-shell/grpc.go // copied from https://github.com/criyle/go-judge/blob/master/cmd/go-judge-shell/grpc.go
func convertPBCmd(cmd []stage.Cmd) []*pb.Request_CmdType { func convertPBCmd(cmd []stage.Cmd) ([]*pb.Request_CmdType, error) {
ret := make([]*pb.Request_CmdType, 0, len(cmd)) ret := make([]*pb.Request_CmdType, 0, len(cmd))
for _, c := range cmd { for index, c := range cmd {
files, err := convertPBFiles([]*stage.CmdFile{c.Stdin, c.Stdout, c.Stderr})
if err != nil {
return nil, fmt.Errorf("command %d standard file: %w", index, err)
}
copyIn, err := convertPBCopyIn(c.CopyIn, c.CopyInDir)
if err != nil {
return nil, fmt.Errorf("command %d copy-in: %w", index, err)
}
req := &pb.Request_CmdType{} req := &pb.Request_CmdType{}
req.SetArgs(c.Args) req.SetArgs(c.Args)
req.SetEnv(c.Env) req.SetEnv(c.Env)
req.SetTty(c.TTY) req.SetTty(c.TTY)
req.SetFiles(convertPBFiles([]*stage.CmdFile{c.Stdin, c.Stdout, c.Stderr})) req.SetFiles(files)
req.SetCpuTimeLimit(c.CPULimit) req.SetCpuTimeLimit(c.CPULimit)
req.SetClockTimeLimit(c.ClockLimit) req.SetClockTimeLimit(c.ClockLimit)
req.SetMemoryLimit(c.MemoryLimit) req.SetMemoryLimit(c.MemoryLimit)
@ -29,7 +37,7 @@ func convertPBCmd(cmd []stage.Cmd) []*pb.Request_CmdType {
req.SetCpuSetLimit(c.CPUSetLimit) req.SetCpuSetLimit(c.CPUSetLimit)
req.SetDataSegmentLimit(c.DataSegmentLimit) req.SetDataSegmentLimit(c.DataSegmentLimit)
req.SetAddressSpaceLimit(c.AddressSpaceLimit) req.SetAddressSpaceLimit(c.AddressSpaceLimit)
req.SetCopyIn(convertPBCopyIn(c.CopyIn, c.CopyInDir)) req.SetCopyIn(copyIn)
req.SetCopyOut(convertPBCopyOut(c.CopyOut)) req.SetCopyOut(convertPBCopyOut(c.CopyOut))
req.SetCopyOutCached(convertPBCopyOut(c.CopyOutCached)) req.SetCopyOutCached(convertPBCopyOut(c.CopyOutCached))
req.SetCopyOutMax(c.CopyOutMax) req.SetCopyOutMax(c.CopyOutMax)
@ -37,25 +45,25 @@ func convertPBCmd(cmd []stage.Cmd) []*pb.Request_CmdType {
req.SetSymlinks(convertSymlink(c.CopyIn)) req.SetSymlinks(convertSymlink(c.CopyIn))
ret = append(ret, req) ret = append(ret, req)
} }
return ret return ret, nil
} }
func convertPBCopyIn( func convertPBCopyIn(
copyIn map[string]stage.CmdFile, copyInDir string, copyIn map[string]stage.CmdFile, copyInDir string,
) map[string]*pb.Request_File { ) (map[string]*pb.Request_File, error) {
if copyInDir != "" { if copyInDir != "" {
_ = filepath.Walk(copyInDir, err := filepath.Walk(copyInDir,
func(path string, info os.FileInfo, err error) error { func(path string, info os.FileInfo, err error) error {
if err != nil { if err != nil {
return nil return err
} }
absPath, err := filepath.Abs(path) absPath, err := filepath.Abs(path)
if err != nil { if err != nil {
return nil return err
} }
relPath, err := filepath.Rel(copyInDir, path) relPath, err := filepath.Rel(copyInDir, path)
if err != nil { if err != nil {
return nil return err
} }
_, exists := copyIn[relPath] _, exists := copyIn[relPath]
if !info.IsDir() && !exists { if !info.IsDir() && !exists {
@ -63,15 +71,22 @@ func convertPBCopyIn(
} }
return nil return nil
}) })
if err != nil {
return nil, fmt.Errorf("walk %q: %w", copyInDir, err)
}
} }
rt := make(map[string]*pb.Request_File, len(copyIn)) rt := make(map[string]*pb.Request_File, len(copyIn))
for k, i := range copyIn { for k, i := range copyIn {
if i.Symlink != nil { if i.Symlink != nil {
continue continue
} }
rt[k] = convertPBFile(i) file, err := convertPBFile(i)
if err != nil {
return nil, fmt.Errorf("file %q: %w", k, err)
} }
return rt rt[k] = file
}
return rt, nil
} }
func convertPBCopyOut(copyOut []string) []*pb.Request_CmdCopyOutFile { func convertPBCopyOut(copyOut []string) []*pb.Request_CmdCopyOutFile {
@ -101,65 +116,67 @@ func convertSymlink(copyIn map[string]stage.CmdFile) map[string]string {
return ret return ret
} }
func convertPBFiles(files []*stage.CmdFile) []*pb.Request_File { func convertPBFiles(files []*stage.CmdFile) ([]*pb.Request_File, error) {
var ret []*pb.Request_File var ret []*pb.Request_File
for _, f := range files { for _, f := range files {
if f == nil { if f == nil {
ret = append(ret, nil) ret = append(ret, nil)
} else { } else {
ret = append(ret, convertPBFile(*f)) file, err := convertPBFile(*f)
if err != nil {
return nil, err
}
ret = append(ret, file)
} }
} }
return ret return ret, nil
} }
func convertPBFile(i stage.CmdFile) *pb.Request_File { func convertPBFile(i stage.CmdFile) (*pb.Request_File, error) {
req := &pb.Request_File{} req := &pb.Request_File{}
switch { switch {
case i.Src != nil: case i.Src != nil:
if !filepath.IsAbs(*i.Src) { if !filepath.IsAbs(*i.Src) {
absPath, err := filepath.Abs(*i.Src) absPath, err := filepath.Abs(*i.Src)
if err != nil { if err != nil {
slog.Error("convert pb file get abs path", "path", *i.Src, "error", err) return nil, fmt.Errorf("resolve source path %q: %w", *i.Src, err)
absPath = "/"
} }
i.Src = &absPath i.Src = &absPath
} }
s, err := os.ReadFile(*i.Src) s, err := os.ReadFile(*i.Src)
if err != nil { if err != nil {
s = []byte{} return nil, fmt.Errorf("read source file %q: %w", *i.Src, err)
slog.Error("convert pb file read file", "path", *i.Src, "error", err)
} }
m := &pb.Request_MemoryFile{} m := &pb.Request_MemoryFile{}
m.SetContent(s) m.SetContent(s)
req.SetMemory(m) req.SetMemory(m)
return req return req, nil
case i.Content != nil: case i.Content != nil:
s := strToBytes(*i.Content) s := strToBytes(*i.Content)
m := &pb.Request_MemoryFile{} m := &pb.Request_MemoryFile{}
m.SetContent(s) m.SetContent(s)
req.SetMemory(m) req.SetMemory(m)
return req return req, nil
case i.FileID != nil: case i.FileID != nil:
c := &pb.Request_CachedFile{} c := &pb.Request_CachedFile{}
c.SetFileID(*i.FileID) c.SetFileID(*i.FileID)
req.SetCached(c) req.SetCached(c)
return req return req, nil
case i.Name != nil && i.Max != nil: case i.Name != nil && i.Max != nil:
p := &pb.Request_PipeCollector{} p := &pb.Request_PipeCollector{}
p.SetName(*i.Name) p.SetName(*i.Name)
p.SetMax(*i.Max) p.SetMax(*i.Max)
p.SetPipe(i.Pipe) p.SetPipe(i.Pipe)
req.SetPipe(p) req.SetPipe(p)
return req return req, nil
case i.StreamIn: case i.StreamIn:
req.SetStreamIn(&emptypb.Empty{}) req.SetStreamIn(&emptypb.Empty{})
return req return req, nil
case i.StreamOut: case i.StreamOut:
req.SetStreamOut(&emptypb.Empty{}) req.SetStreamOut(&emptypb.Empty{})
return req return req, nil
} }
return nil return nil, nil
} }
func convertPBResult(res []*pb.Response_Result) []stage.ExecutorResult { func convertPBResult(res []*pb.Response_Result) []stage.ExecutorResult {

View File

@ -0,0 +1,121 @@
package sandbox
import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"github.com/criyle/go-judge/pb"
"github.com/joint-online-judge/JOJ3/internal/stage"
)
func TestConvertPBFileVariants(t *testing.T) {
content, fileID, name := "content", "cached", "output"
max := int64(123)
tests := []struct {
name string
file stage.CmdFile
check func(*pb.Request_File) bool
}{
{"content", stage.CmdFile{Content: &content}, func(f *pb.Request_File) bool { return string(f.GetMemory().GetContent()) == content }},
{"cached", stage.CmdFile{FileID: &fileID}, func(f *pb.Request_File) bool { return f.GetCached().GetFileID() == fileID }},
{"pipe", stage.CmdFile{Name: &name, Max: &max, Pipe: true}, func(f *pb.Request_File) bool {
return f.GetPipe().GetName() == name && f.GetPipe().GetMax() == max && f.GetPipe().GetPipe()
}},
{"stream-in", stage.CmdFile{StreamIn: true}, func(f *pb.Request_File) bool { return f.GetStreamIn() != nil }},
{"stream-out", stage.CmdFile{StreamOut: true}, func(f *pb.Request_File) bool { return f.GetStreamOut() != nil }},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := convertPBFile(tt.file)
if err != nil || got == nil || !tt.check(got) {
t.Fatalf("convertPBFile() = %v, %v", got, err)
}
})
}
}
func TestConvertCopyOutAndResult(t *testing.T) {
copyOut := convertPBCopyOut([]string{"required", "optional?"})
if len(copyOut) != 2 || copyOut[0].GetOptional() || !copyOut[1].GetOptional() || copyOut[1].GetName() != "optional" {
t.Fatalf("convertPBCopyOut() = %+v", copyOut)
}
fileError := &pb.Response_FileError{}
fileError.SetName("input")
fileError.SetType(pb.Response_FileError_ErrorType(1))
fileError.SetMessage("bad file")
response := &pb.Response_Result{}
response.SetStatus(pb.Response_Result_StatusType(stage.StatusAccepted))
response.SetFiles(map[string][]byte{"stdout": []byte("ok")})
response.SetFileIDs(map[string]string{"bin": "id"})
response.SetFileError([]*pb.Response_FileError{fileError})
got := convertPBResult([]*pb.Response_Result{response})
if len(got) != 1 || got[0].Files["stdout"] != "ok" || got[0].FileIDs["bin"] != "id" ||
len(got[0].FileError) != 1 || got[0].FileError[0].Message != "bad file" {
t.Fatalf("convertPBResult() = %+v", got)
}
}
func TestConvertPBCmdReturnsSourceReadError(t *testing.T) {
missing := t.TempDir() + "/missing"
_, err := convertPBCmd([]stage.Cmd{{
Args: []string{"true"},
CopyIn: map[string]stage.CmdFile{
"input": {Src: &missing},
},
}})
if err == nil || !strings.Contains(err.Error(), "read source file") {
t.Fatalf("convertPBCmd() error = %v, want source read error", err)
}
}
func TestConvertPBCmdReturnsStandardFileReadError(t *testing.T) {
missing := t.TempDir() + "/missing"
_, err := convertPBCmd([]stage.Cmd{{
Args: []string{"true"},
Stdin: &stage.CmdFile{Src: &missing},
}})
if err == nil || !strings.Contains(err.Error(), "standard file") ||
!strings.Contains(err.Error(), "read source file") {
t.Fatalf("convertPBCmd() error = %v, want standard source read error", err)
}
}
func TestConvertPBCmdReturnsCopyInDirectoryWalkError(t *testing.T) {
missing := t.TempDir() + "/missing"
_, err := convertPBCmd([]stage.Cmd{{
Args: []string{"true"},
CopyInDir: missing,
}})
if err == nil || !strings.Contains(err.Error(), "walk") {
t.Fatalf("convertPBCmd() error = %v, want directory walk error", err)
}
}
func TestConvertPBCmdPreservesFilesAcrossMultipleSCMMaxFDBatches(t *testing.T) {
// 1001 is large enough to require several descriptor batches while keeping
// the fixture cheap to create and the protobuf request small.
const fileCount = 1001
dir := t.TempDir()
for i := range fileCount {
path := filepath.Join(dir, fmt.Sprintf("file-%03d", i))
if err := os.WriteFile(path, []byte("content"), 0o600); err != nil {
t.Fatal(err)
}
}
cmds, err := convertPBCmd([]stage.Cmd{{
Args: []string{"true"},
CopyIn: make(map[string]stage.CmdFile),
CopyInDir: dir,
}})
if err != nil {
t.Fatal(err)
}
if len(cmds) != 1 || len(cmds[0].GetCopyIn()) != fileCount {
t.Fatalf("converted %d commands with %d files, want 1 command with %d files",
len(cmds), len(cmds[0].GetCopyIn()), fileCount)
}
}

View File

@ -2,20 +2,23 @@ package sandbox
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"log/slog" "log/slog"
"maps" "maps"
"math"
"time"
"github.com/criyle/go-judge/pb" "github.com/criyle/go-judge/pb"
"github.com/joint-online-judge/JOJ3/internal/stage" "github.com/joint-online-judge/JOJ3/internal/stage"
"google.golang.org/protobuf/proto" "google.golang.org/protobuf/proto"
) )
func (e *Sandbox) Run(cmds []stage.Cmd) ([]stage.ExecutorResult, error) { func (e *Sandbox) Run(ctx context.Context, cmds []stage.Cmd) ([]stage.ExecutorResult, error) {
var err error var err error
if e.execClient == nil { if e.execClient == nil {
slog.Debug("create exec client", "server", e.execServer) slog.Debug("create exec client", "server", e.execServer)
e.execClient, err = createExecClient(e.execServer, e.token) e.execClient, e.conn, err = createExecClient(e.execServer, e.token)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -32,14 +35,19 @@ func (e *Sandbox) Run(cmds []stage.Cmd) ([]stage.ExecutorResult, error) {
} }
} }
} }
pbCmds := convertPBCmd(cmds) pbCmds, err := convertPBCmd(cmds)
if err != nil {
return nil, err
}
for i, pbCmd := range pbCmds { for i, pbCmd := range pbCmds {
slog.Debug("sandbox execute", "i", i, "pbCmd size", proto.Size(pbCmd)) slog.Debug("sandbox execute", "i", i, "pbCmd size", proto.Size(pbCmd))
} }
pbReq := &pb.Request{} pbReq := &pb.Request{}
pbReq.SetCmd(pbCmds) pbReq.SetCmd(pbCmds)
slog.Debug("sandbox execute", "pbReq size", proto.Size(pbReq)) slog.Debug("sandbox execute", "pbReq size", proto.Size(pbReq))
pbRet, err := e.execClient.Exec(context.TODO(), pbReq) callCtx, cancel := context.WithTimeout(ctx, execRPCTimeout(cmds))
defer cancel()
pbRet, err := e.execClient.Exec(callCtx, pbReq)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -53,15 +61,50 @@ func (e *Sandbox) Run(cmds []stage.Cmd) ([]stage.ExecutorResult, error) {
return results, nil return results, nil
} }
func (e *Sandbox) Cleanup() error { func (e *Sandbox) Cleanup(ctx context.Context) error {
var cleanupErr error
for k, fileID := range e.cachedMap { for k, fileID := range e.cachedMap {
req := &pb.FileID{} req := &pb.FileID{}
req.SetFileID(fileID) req.SetFileID(fileID)
_, err := e.execClient.FileDelete(context.TODO(), req) callCtx, cancel := context.WithTimeout(ctx, rpcTimeoutMargin)
_, err := e.execClient.FileDelete(callCtx, req)
cancel()
if err != nil { if err != nil {
slog.Error("sandbox cleanup", "error", err) slog.Error("sandbox cleanup", "error", err)
cleanupErr = errors.Join(cleanupErr, err)
} }
delete(e.cachedMap, k) delete(e.cachedMap, k)
} }
return nil if e.conn != nil {
cleanupErr = errors.Join(cleanupErr, e.conn.Close())
e.conn = nil
e.execClient = nil
}
return cleanupErr
}
func execRPCTimeout(cmds []stage.Cmd) time.Duration {
var maxLimit uint64
for _, cmd := range cmds {
limit := cmd.ClockLimit
if limit == 0 {
// Match the local executor's default wall-clock allowance when only a
// CPU limit is specified.
if cmd.CPULimit > math.MaxUint64/2 {
limit = math.MaxUint64
} else {
limit = cmd.CPULimit * 2
}
}
if limit > maxLimit {
maxLimit = limit
}
}
margin := uint64(rpcTimeoutMargin)
if maxLimit > uint64(math.MaxInt64)-margin {
return time.Duration(math.MaxInt64)
}
// The bound above guarantees the conversion fits in time.Duration.
return time.Duration(maxLimit + margin) // #nosec G115
} }

View File

@ -0,0 +1,129 @@
package sandbox
import (
"context"
"errors"
"math"
"testing"
"time"
"github.com/criyle/go-judge/pb"
"github.com/joint-online-judge/JOJ3/internal/stage"
"google.golang.org/grpc"
"google.golang.org/protobuf/types/known/emptypb"
)
type fakeExecutorClient struct {
pb.ExecutorClient
exec func(context.Context, *pb.Request) (*pb.Response, error)
fileDelete func(context.Context, *pb.FileID) (*emptypb.Empty, error)
}
func (f fakeExecutorClient) Exec(
ctx context.Context, req *pb.Request, _ ...grpc.CallOption,
) (*pb.Response, error) {
return f.exec(ctx, req)
}
func (f fakeExecutorClient) FileDelete(
ctx context.Context, id *pb.FileID, _ ...grpc.CallOption,
) (*emptypb.Empty, error) {
return f.fileDelete(ctx, id)
}
func TestExecRPCTimeout(t *testing.T) {
tests := []struct {
name string
cmds []stage.Cmd
want time.Duration
}{
{name: "margin only", want: rpcTimeoutMargin},
{
name: "largest clock limit",
cmds: []stage.Cmd{{ClockLimit: uint64(time.Minute)}, {ClockLimit: uint64(2 * time.Minute)}},
want: 2*time.Minute + rpcTimeoutMargin,
},
{
name: "cpu limit fallback",
cmds: []stage.Cmd{{CPULimit: uint64(time.Minute)}},
want: 2*time.Minute + rpcTimeoutMargin,
},
{
name: "clock limit takes precedence over cpu limit",
cmds: []stage.Cmd{{ClockLimit: uint64(time.Minute), CPULimit: uint64(10 * time.Minute)}},
want: time.Minute + rpcTimeoutMargin,
},
{
name: "cpu multiplication overflow",
cmds: []stage.Cmd{{CPULimit: math.MaxUint64}},
want: time.Duration(math.MaxInt64),
},
{
name: "duration overflow",
cmds: []stage.Cmd{{ClockLimit: math.MaxUint64}},
want: time.Duration(math.MaxInt64),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := execRPCTimeout(tt.cmds); got != tt.want {
t.Fatalf("execRPCTimeout() = %v, want %v", got, tt.want)
}
})
}
}
func TestRunAppliesComputedRPCDeadline(t *testing.T) {
var remaining time.Duration
client := fakeExecutorClient{
exec: func(ctx context.Context, _ *pb.Request) (*pb.Response, error) {
deadline, ok := ctx.Deadline()
if !ok {
t.Fatal("Exec context has no deadline")
}
remaining = time.Until(deadline)
return &pb.Response{}, nil
},
}
executor := &Sandbox{
execClient: client,
cachedMap: make(map[string]string),
}
want := 2*time.Minute + rpcTimeoutMargin
_, err := executor.Run(context.Background(), []stage.Cmd{{
Args: []string{"true"},
CPULimit: uint64(time.Minute),
}})
if err != nil {
t.Fatal(err)
}
if remaining > want || remaining < want-time.Second {
t.Fatalf("RPC deadline remaining = %v, want approximately %v", remaining, want)
}
}
func TestCleanupJoinsDeleteErrorsAndClearsCache(t *testing.T) {
deleteErr := errors.New("delete failed")
deleted := 0
client := fakeExecutorClient{
fileDelete: func(context.Context, *pb.FileID) (*emptypb.Empty, error) {
deleted++
return nil, deleteErr
},
}
executor := &Sandbox{
execClient: client,
cachedMap: map[string]string{"one": "1", "two": "2"},
}
err := executor.Cleanup(context.Background())
if !errors.Is(err, deleteErr) {
t.Fatalf("Cleanup() error = %v, want delete error", err)
}
if deleted != 2 {
t.Fatalf("FileDelete called %d times, want 2", deleted)
}
if len(executor.cachedMap) != 0 {
t.Fatalf("cached files not cleared: %v", executor.cachedMap)
}
}

View File

@ -12,13 +12,13 @@ import (
) )
// copied from https://github.com/criyle/go-judger-demo/blob/master/apigateway/main.go // copied from https://github.com/criyle/go-judger-demo/blob/master/apigateway/main.go
func createExecClient(execServer, token string) (pb.ExecutorClient, error) { func createExecClient(execServer, token string) (pb.ExecutorClient, *grpc.ClientConn, error) {
conn, err := createGRPCConnection(execServer, token) conn, err := createGRPCConnection(execServer, token)
if err != nil { if err != nil {
slog.Error("gRPC connection", "error", err) slog.Error("gRPC connection", "error", err)
return nil, err return nil, nil, err
} }
return pb.NewExecutorClient(conn), nil return pb.NewExecutorClient(conn), conn, nil
} }
func createGRPCConnection(addr, token string) (*grpc.ClientConn, error) { func createGRPCConnection(addr, token string) (*grpc.ClientConn, error) {

View File

@ -0,0 +1,27 @@
package sandbox
import (
"context"
"testing"
)
func TestTokenAuth(t *testing.T) {
auth := newTokenAuth("secret")
metadata, err := auth.GetRequestMetadata(context.Background())
if err != nil || metadata["authorization"] != "Bearer secret" {
t.Fatalf("GetRequestMetadata() = %v, %v", metadata, err)
}
if auth.RequireTransportSecurity() {
t.Fatal("RequireTransportSecurity() = true")
}
}
func TestCreateGRPCConnection(t *testing.T) {
conn, err := createGRPCConnection("passthrough:///unused", "secret")
if err != nil {
t.Fatal(err)
}
if err := conn.Close(); err != nil {
t.Fatal(err)
}
}

View File

@ -5,8 +5,11 @@
package sandbox package sandbox
import ( import (
"time"
"github.com/criyle/go-judge/pb" "github.com/criyle/go-judge/pb"
"github.com/joint-online-judge/JOJ3/internal/stage" "github.com/joint-online-judge/JOJ3/internal/stage"
"google.golang.org/grpc"
) )
var name = "sandbox" var name = "sandbox"
@ -15,8 +18,11 @@ type Sandbox struct {
execServer, token string execServer, token string
cachedMap map[string]string cachedMap map[string]string
execClient pb.ExecutorClient execClient pb.ExecutorClient
conn *grpc.ClientConn
} }
const rpcTimeoutMargin = 30 * time.Second
func init() { func init() {
stage.RegisterExecutor(name, &Sandbox{ stage.RegisterExecutor(name, &Sandbox{
execServer: "localhost:5051", execServer: "localhost:5051",

View File

@ -0,0 +1,38 @@
package keyword
import (
"strings"
"testing"
"github.com/joint-online-judge/JOJ3/internal/stage"
)
func TestRunCountsCapsAndOrdersKeywords(t *testing.T) {
results, forceQuit, err := (&Keyword{}).Run([]stage.ExecutorResult{{
Files: map[string]string{"log": "error error warning"},
}}, map[string]any{
"score": 10,
"files": []any{"log"},
"forceQuitOnDeduct": true,
"matches": []any{
map[string]any{"keywords": []any{"error"}, "score": 3, "maxMatchCount": 1},
map[string]any{"keywords": []any{"warning"}, "score": 2},
},
})
if err != nil {
t.Fatal(err)
}
if len(results) != 1 || results[0].Score != 5 || !forceQuit {
t.Fatalf("Run() = %+v, %v", results, forceQuit)
}
if !strings.Contains(results[0].Comment, "`error`: 1") || !strings.Contains(results[0].Comment, "`warning`: 1") {
t.Fatalf("comment = %q", results[0].Comment)
}
}
func TestRunRejectsInvalidConfiguration(t *testing.T) {
_, forceQuit, err := (&Keyword{}).Run(nil, "invalid")
if err == nil || !forceQuit {
t.Fatalf("Run() = forceQuit %v, error %v", forceQuit, err)
}
}

View File

@ -1,6 +1,7 @@
package stage package stage
import ( import (
"context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"strconv" "strconv"
@ -9,8 +10,8 @@ import (
var executorMap = map[string]Executor{} var executorMap = map[string]Executor{}
type Executor interface { type Executor interface {
Run([]Cmd) ([]ExecutorResult, error) Run(context.Context, []Cmd) ([]ExecutorResult, error)
Cleanup() error Cleanup(context.Context) error
} }
func RegisterExecutor(name string, executor Executor) { func RegisterExecutor(name string, executor Executor) {

View File

@ -60,7 +60,7 @@ func (t FileErrorType) MarshalJSON() ([]byte, error) {
func (t *FileErrorType) UnmarshalJSON(b []byte) error { func (t *FileErrorType) UnmarshalJSON(b []byte) error {
str := string(b) str := string(b)
v, ok := fileErrorStringReverse[str] v, ok := fileErrorStringReverse[str]
if ok { if !ok {
return fmt.Errorf("%s is not file error type", str) return fmt.Errorf("%s is not file error type", str)
} }
*t = v *t = v

View File

@ -0,0 +1,67 @@
package stage
import (
"encoding/json"
"strings"
"testing"
)
func TestStatusJSONRoundTrip(t *testing.T) {
for status := StatusInvalid; status <= StatusInternalError; status++ {
data, err := json.Marshal(status)
if err != nil {
t.Fatal(err)
}
var got Status
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("unmarshal %s: %v", data, err)
}
if got != status {
t.Fatalf("round trip = %v, want %v", got, status)
}
}
var status Status
if err := json.Unmarshal([]byte(`"unknown"`), &status); err == nil {
t.Fatal("unknown status was accepted")
}
}
func TestFileErrorTypeJSONRoundTrip(t *testing.T) {
for fileError := ErrCopyInOpenFile; fileError <= ErrCollectSizeExceeded; fileError++ {
data, err := json.Marshal(fileError)
if err != nil {
t.Fatal(err)
}
var got FileErrorType
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("unmarshal %s: %v", data, err)
}
if got != fileError {
t.Fatalf("round trip = %v, want %v", got, fileError)
}
}
var fileError FileErrorType
if err := json.Unmarshal([]byte(`"unknown"`), &fileError); err == nil {
t.Fatal("unknown file error type was accepted")
}
}
func TestExecutorResultJSONSummarizesFileContents(t *testing.T) {
data, err := json.Marshal(ExecutorResult{
Status: StatusAccepted,
Files: map[string]string{"stdout": "secret output"},
})
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(data), "secret output") || !strings.Contains(string(data), `"stdout":"len:13"`) {
t.Fatalf("MarshalJSON() = %s", data)
}
}
func TestNonNullSliceMarshalsEmptyAsArray(t *testing.T) {
data, err := json.Marshal(NonNullSlice[int](nil))
if err != nil || string(data) != "[]" {
t.Fatalf("MarshalJSON() = %s, %v", data, err)
}
}

View File

@ -4,11 +4,13 @@
package stage package stage
import ( import (
"context"
"errors"
"fmt" "fmt"
"log/slog" "log/slog"
) )
func Run(stages []Stage) ( func Run(ctx context.Context, stages []Stage) (
stageResults []StageResult, forceQuitStageName string, err error, stageResults []StageResult, forceQuitStageName string, err error,
) { ) {
var executorResults []ExecutorResult var executorResults []ExecutorResult
@ -59,9 +61,10 @@ func Run(stages []Stage) (
"name", stage.Executor.Name, "name", stage.Executor.Name,
) )
err = fmt.Errorf("executor not found: %s", stage.Executor.Name) err = fmt.Errorf("executor not found: %s", stage.Executor.Name)
forceQuitStageName = stage.Name
return return
} }
executorResults, err = executor.Run(stage.Executor.Cmds) executorResults, err = executor.Run(ctx, stage.Executor.Cmds)
if err != nil { if err != nil {
slog.Error( slog.Error(
"executor run error", "executor run error",
@ -69,6 +72,7 @@ func Run(stages []Stage) (
"name", stage.Executor.Name, "name", stage.Executor.Name,
"error", err, "error", err,
) )
forceQuitStageName = stage.Name
return return
} }
for i, executorResult := range executorResults { for i, executorResult := range executorResults {
@ -115,6 +119,7 @@ func Run(stages []Stage) (
"name", stageParser.Name, "name", stageParser.Name,
) )
err = fmt.Errorf("parser not found: %s", stageParser.Name) err = fmt.Errorf("parser not found: %s", stageParser.Name)
forceQuitStageName = stage.Name
return return
} }
var parserForceQuit bool var parserForceQuit bool
@ -130,6 +135,14 @@ func Run(stages []Stage) (
forceQuitStageName = stage.Name forceQuitStageName = stage.Name
break break
} }
if len(tmpParserResults) != len(executorResults) {
err = fmt.Errorf(
"parser %q returned %d results for %d executor results",
stageParser.Name, len(tmpParserResults), len(executorResults),
)
forceQuitStageName = stage.Name
break
}
for i, parserResult := range tmpParserResults { for i, parserResult := range tmpParserResults {
parserScoresMap[stageParser.Name][i] += parserResult.Score parserScoresMap[stageParser.Name][i] += parserResult.Score
} }
@ -185,12 +198,15 @@ func Run(stages []Stage) (
return stageResults, forceQuitStageName, err return stageResults, forceQuitStageName, err
} }
func Cleanup() { func Cleanup(ctx context.Context) error {
slog.Info("stage cleanup start") slog.Info("stage cleanup start")
var cleanupErr error
for name, executor := range executorMap { for name, executor := range executorMap {
err := executor.Cleanup() err := executor.Cleanup(ctx)
if err != nil { if err != nil {
slog.Error("executor cleanup error", "name", name, "error", err) slog.Error("executor cleanup error", "name", name, "error", err)
cleanupErr = errors.Join(cleanupErr, fmt.Errorf("executor %q cleanup: %w", name, err))
} }
} }
return cleanupErr
} }

149
internal/stage/run_test.go Normal file
View File

@ -0,0 +1,149 @@
package stage
import (
"context"
"errors"
"strings"
"testing"
)
type contractTestExecutor struct{}
func (contractTestExecutor) Run(context.Context, []Cmd) ([]ExecutorResult, error) {
return []ExecutorResult{{Status: StatusAccepted}}, nil
}
func (contractTestExecutor) Cleanup(context.Context) error { return nil }
type contractTestParser struct{}
func (contractTestParser) Run([]ExecutorResult, any) ([]ParserResult, bool, error) {
return nil, false, nil
}
func TestRunRejectsParserResultCountMismatch(t *testing.T) {
originalExecutors, originalParsers := executorMap, parserMap
executorMap, parserMap = map[string]Executor{}, map[string]Parser{}
t.Cleanup(func() { executorMap, parserMap = originalExecutors, originalParsers })
const executorName = "contract-test-executor"
const parserName = "contract-test-parser"
RegisterExecutor(executorName, contractTestExecutor{})
RegisterParser(parserName, contractTestParser{})
_, forceQuit, err := Run(context.Background(), []Stage{{
Name: "test",
Executor: StageExecutor{Name: executorName, Cmds: []Cmd{{}}},
Parsers: []StageParser{{Name: parserName}},
}})
if err == nil || !strings.Contains(err.Error(), "returned 0 results for 1") {
t.Fatalf("Run() error = %v, want result count error", err)
}
if forceQuit != "test" {
t.Fatalf("Run() force quit = %q, want test", forceQuit)
}
}
type executorFunc struct {
run func(context.Context, []Cmd) ([]ExecutorResult, error)
cleanup func(context.Context) error
}
func (e executorFunc) Run(ctx context.Context, cmds []Cmd) ([]ExecutorResult, error) {
return e.run(ctx, cmds)
}
func (e executorFunc) Cleanup(ctx context.Context) error {
if e.cleanup == nil {
return nil
}
return e.cleanup(ctx)
}
type parserFunc func([]ExecutorResult, any) ([]ParserResult, bool, error)
func (p parserFunc) Run(results []ExecutorResult, conf any) ([]ParserResult, bool, error) {
return p(results, conf)
}
func isolateRegistries(t *testing.T) {
t.Helper()
originalExecutors, originalParsers := executorMap, parserMap
executorMap, parserMap = map[string]Executor{}, map[string]Parser{}
t.Cleanup(func() { executorMap, parserMap = originalExecutors, originalParsers })
}
func TestCleanupJoinsExecutorErrors(t *testing.T) {
isolateRegistries(t)
errOne := errors.New("cleanup one")
errTwo := errors.New("cleanup two")
RegisterExecutor("one", executorFunc{
run: func(context.Context, []Cmd) ([]ExecutorResult, error) { return nil, nil },
cleanup: func(context.Context) error { return errOne },
})
RegisterExecutor("two", executorFunc{
run: func(context.Context, []Cmd) ([]ExecutorResult, error) { return nil, nil },
cleanup: func(context.Context) error { return errTwo },
})
err := Cleanup(context.Background())
if !errors.Is(err, errOne) || !errors.Is(err, errTwo) {
t.Fatalf("Cleanup() error = %v, want both cleanup errors", err)
}
}
func TestRunStopsAfterParserContractViolation(t *testing.T) {
isolateRegistries(t)
RegisterExecutor("executor", executorFunc{
run: func(context.Context, []Cmd) ([]ExecutorResult, error) {
return []ExecutorResult{{Status: StatusAccepted}}, nil
},
})
RegisterParser("bad", parserFunc(func([]ExecutorResult, any) ([]ParserResult, bool, error) {
return nil, false, nil
}))
secondCalled := false
RegisterParser("second", parserFunc(func([]ExecutorResult, any) ([]ParserResult, bool, error) {
secondCalled = true
return []ParserResult{{}}, false, nil
}))
_, forceQuit, err := Run(context.Background(), []Stage{{
Name: "contract",
Executor: StageExecutor{Name: "executor", Cmds: []Cmd{{}}},
Parsers: []StageParser{{Name: "bad"}, {Name: "second"}},
}})
if err == nil || forceQuit != "contract" {
t.Fatalf("Run() = forceQuit %q, error %v", forceQuit, err)
}
if secondCalled {
t.Fatal("parser after contract violation was called")
}
}
func TestRunSetsForceQuitForMissingComponents(t *testing.T) {
tests := []struct {
name string
stage Stage
}{
{name: "executor", stage: Stage{Name: "missing-executor", Executor: StageExecutor{Name: "unknown"}}},
{
name: "parser",
stage: Stage{
Name: "missing-parser",
Executor: StageExecutor{Name: "executor", Cmds: []Cmd{{}}},
Parsers: []StageParser{{Name: "unknown"}},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
isolateRegistries(t)
RegisterExecutor("executor", contractTestExecutor{})
_, forceQuit, err := Run(context.Background(), []Stage{tt.stage})
if err == nil || forceQuit != tt.stage.Name {
t.Fatalf("Run() = forceQuit %q, error %v", forceQuit, err)
}
})
}
}

View File

@ -0,0 +1,80 @@
package healthcheck
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing/object"
)
func newCommitRepo(t *testing.T, message, email string) string {
t.Helper()
dir := t.TempDir()
repo, err := git.PlainInit(dir, false)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("test"), 0o600); err != nil {
t.Fatal(err)
}
worktree, err := repo.Worktree()
if err != nil {
t.Fatal(err)
}
if _, err := worktree.Add("README.md"); err != nil {
t.Fatal(err)
}
signature := &object.Signature{Name: "Student", Email: email, When: time.Unix(1, 0)}
if _, err := worktree.Commit(message, &git.CommitOptions{Author: signature, Committer: signature}); err != nil {
t.Fatal(err)
}
return dir
}
func TestCommitChecks(t *testing.T) {
valid := newCommitRepo(t, "feat: valid", "student@example.edu")
if err := NonASCIIMsg(valid); err != nil {
t.Fatal(err)
}
if err := AuthorEmailCheck(valid, []string{"example.edu"}, filepath.Join(valid, "missing.csv")); err != nil {
t.Fatal(err)
}
invalidMessage := newCommitRepo(t, "feat: 测试", "student@invalid.test")
if err := NonASCIIMsg(invalidMessage); err == nil || !strings.Contains(err.Error(), "测试") {
t.Fatalf("NonASCIIMsg() error = %v", err)
}
if err := AuthorEmailCheck(invalidMessage, []string{"example.edu"}, filepath.Join(invalidMessage, "missing.csv")); err == nil || !strings.Contains(err.Error(), "allowed domains") {
t.Fatalf("AuthorEmailCheck(domain) error = %v", err)
}
}
func TestAuthorEmailCheckActorCSV(t *testing.T) {
repo := newCommitRepo(t, "feat: valid", "student@example.edu")
csvPath := filepath.Join(t.TempDir(), "actors.csv")
if err := os.WriteFile(csvPath, []byte("name,id,student\n"), 0o600); err != nil {
t.Fatal(err)
}
if err := AuthorEmailCheck(repo, []string{"example.edu"}, csvPath); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(csvPath, []byte("name,id,other\n"), 0o600); err != nil {
t.Fatal(err)
}
if err := AuthorEmailCheck(repo, []string{"example.edu"}, csvPath); err == nil || !strings.Contains(err.Error(), "not stored") {
t.Fatalf("AuthorEmailCheck(actor) error = %v", err)
}
}
func TestCommitChecksRejectNonRepository(t *testing.T) {
if err := NonASCIIMsg(t.TempDir()); err == nil || !strings.Contains(err.Error(), "opening git repo") {
t.Fatalf("NonASCIIMsg() error = %v", err)
}
if err := AuthorEmailCheck(t.TempDir(), nil, ""); err == nil || !strings.Contains(err.Error(), "opening git repo") {
t.Fatalf("AuthorEmailCheck() error = %v", err)
}
}

View File

@ -0,0 +1,67 @@
package healthcheck
import (
"crypto/sha256"
"encoding/hex"
"os"
"path/filepath"
"strings"
"testing"
)
func TestParseWhitelistedChars(t *testing.T) {
got := parseWhitelistedChars("你, 好, a, invalid, ,你")
if len(got) != 2 {
t.Fatalf("parseWhitelistedChars() = %v", got)
}
if _, ok := got['你']; !ok {
t.Fatal("missing whitelisted rune")
}
}
func TestNonASCIIFiles(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "source.txt"), []byte("hello 你\n"), 0o600); err != nil {
t.Fatal(err)
}
if err := NonASCIIFiles(dir, "你"); err != nil {
t.Fatalf("whitelisted NonASCIIFiles() error = %v", err)
}
if err := NonASCIIFiles(dir, ""); err == nil || !strings.Contains(err.Error(), "source.txt") {
t.Fatalf("NonASCIIFiles() error = %v", err)
}
}
func TestForbiddenCheck(t *testing.T) {
dir := t.TempDir()
t.Chdir(dir)
if err := os.WriteFile(filepath.Join(dir, ".gitignore"), []byte("*.out\n"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "result.out"), nil, 0o600); err != nil {
t.Fatal(err)
}
if err := ForbiddenCheck("."); err == nil || !strings.Contains(err.Error(), "result.out") {
t.Fatalf("ForbiddenCheck() error = %v", err)
}
}
func TestVerifyFiles(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "protected.txt")
content := []byte("original")
if err := os.WriteFile(path, content, 0o600); err != nil {
t.Fatal(err)
}
sum := sha256.Sum256(content)
checksum := hex.EncodeToString(sum[:])
if err := VerifyFiles(dir, "protected.txt", checksum); err != nil {
t.Fatal(err)
}
if err := VerifyFiles(dir, "protected.txt", "bad"); err == nil || !strings.Contains(err.Error(), "altered") {
t.Fatalf("VerifyFiles(altered) error = %v", err)
}
if err := VerifyFiles(dir, "one,two", checksum); err == nil || !strings.Contains(err.Error(), "do not match") {
t.Fatalf("VerifyFiles(mismatch) error = %v", err)
}
}