diff --git a/Makefile b/Makefile index 6c65b39..be9df46 100644 --- a/Makefile +++ b/Makefile @@ -8,6 +8,7 @@ DATE := $(shell date +"%Y%m%d-%H%M%S") VERSION := $(COMMIT_HASH)-$(DATE) LDFLAGS := -s -w -X main.Version=$(VERSION) GOFLAGS := -trimpath -mod=readonly -buildvcs=false +COVERAGE_FILE ?= coverage.out all: build @@ -27,18 +28,15 @@ lint: prepare-test: git submodule update --init --remote -test: +test: build ./scripts/prepare_test_repos.sh $(TMP_DIR) # no clang-tidy-18 locally rm -rf $(TMP_DIR)/submodules/JOJ3-examples/examples/keyword/clangtidy - go test -count=1 -v ./... - -local-test: - rm -rf $(TMP_DIR)/submodules/JOJ3-examples/examples/ - mkdir -p $(TMP_DIR)/submodules/JOJ3-examples/examples/ - go test -count=1 -v ./... + go test -count=1 -v -coverpkg=./... -coverprofile=$(COVERAGE_FILE) ./... + go tool cover -func=$(COVERAGE_FILE) | tail -n 1 ci-test: ./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" - 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 diff --git a/README.md b/README.md index ec4e78f..8629a51 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 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) [![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. - 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. + - 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. 4. Run stages. - By default, all the stages will run sequentially. diff --git a/cmd/joj3/conf/conf.go b/cmd/joj3/conf/conf.go index ac1a90b..876a7d0 100644 --- a/cmd/joj3/conf/conf.go +++ b/cmd/joj3/conf/conf.go @@ -175,6 +175,9 @@ func GetConfPath(confRoot, confName, fallbackConfName, msg, tag string) ( hintValidScopes(confRoot, confName) } slog.Error("stat conf", "error", err) + if tag != "" { + return confPath, confStat, conventionalCommit, err + } // fallback to conf file in conf root on conf not exist confPath = filepath.Join(confRoot, fallbackConfName) 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 { seen := make(map[string]bool) keywords := []string{} - loweredCommitGroup := strings.ToLower(conventionalCommit.Group) - matchAllGroups := loweredCommitGroup == "all" + requestedGroups := make(map[string]bool) + 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 = append(confStages, conf.PreStages...) confStages = append(confStages, conf.Stages...) @@ -221,7 +229,7 @@ func MatchGroups(conf *Conf, conventionalCommit *ConventionalCommit) []string { slog.Info("group keywords from stages", "keywords", keywords) groups := []string{} for _, keyword := range keywords { - if matchAllGroups || strings.Contains(loweredCommitGroup, keyword) { + if matchAllGroups || requestedGroups[keyword] { groups = append(groups, keyword) } } diff --git a/cmd/joj3/conf/conf_test.go b/cmd/joj3/conf/conf_test.go index a52f737..a7528c8 100644 --- a/cmd/joj3/conf/conf_test.go +++ b/cmd/joj3/conf/conf_test.go @@ -1,10 +1,67 @@ package conf import ( + "crypto/sha256" + "encoding/hex" + "os" + "path/filepath" "reflect" + "strings" "testing" ) +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) { tests := []struct { name string @@ -130,3 +187,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) + } + }) + } +} diff --git a/cmd/joj3/main.go b/cmd/joj3/main.go index a872ea7..4bf5d50 100644 --- a/cmd/joj3/main.go +++ b/cmd/joj3/main.go @@ -104,6 +104,7 @@ func run(conf *joj3Conf.Conf, conventionalCommit *joj3Conf.ConventionalCommit) e ) if err != nil { slog.Error("stage run", "error", err) + return err } if forceQuitStageName != "" { slog.Info("stage force quit", "name", forceQuitStageName) diff --git a/cmd/joj3/main_test.go b/cmd/joj3/main_test.go index edf1767..affafcc 100644 --- a/cmd/joj3/main_test.go +++ b/cmd/joj3/main_test.go @@ -87,7 +87,16 @@ func TestRun(t *testing.T) { t.Fatal(err) } 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) { + if tt == "healthcheck/release" { + prepareLargeCopyInFixture(t, filepath.Join(root, tt)) + } t.Chdir(fmt.Sprintf("%s%s", root, tt)) os.Args = []string{"./joj3"} 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) + } + }) +} diff --git a/cmd/joj3/stage.go b/cmd/joj3/stage.go index 12fc926..a68dc69 100644 --- a/cmd/joj3/stage.go +++ b/cmd/joj3/stage.go @@ -1,7 +1,9 @@ package main import ( + "context" "encoding/json" + "errors" "fmt" "log/slog" "os" @@ -124,6 +126,7 @@ func newErrorStageResults(err error) ([]stage.StageResult, string) { }, "Internal Error" } +//nolint:unparam // named err lets deferred cleanup errors reach the caller func runStages( conf *conf.Conf, groups []string, @@ -153,36 +156,42 @@ func runStages( stageResults, forceQuitStageName = newErrorStageResults(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 slog.Info("run preStages") - _, _, err = stage.Run(preStages) - if err != nil { - slog.Error("run preStages", "error", err) + _, _, preErr := stage.Run(ctx, preStages) + if preErr != nil { + slog.Error("run preStages", "error", preErr) } slog.Info("run stages") - stageResults, forceQuitStageName, err = stage.Run(stages) - if err != nil { - slog.Error("run stages", "error", err) - stageResults, forceQuitStageName = newErrorStageResults(err) + stageResults, forceQuitStageName, mainErr := stage.Run(ctx, stages) + if mainErr != nil { + slog.Error("run stages", "error", mainErr) + stageResults, forceQuitStageName = newErrorStageResults(mainErr) } onStagesComplete(stageResults, forceQuitStageName) slog.Info("output result start", "path", conf.OutputPath) slog.Debug("output result start", "path", conf.OutputPath, "results", stageResults) - content, err := json.Marshal(stageResults) - if err != nil { - slog.Error("marshal stageResults", "error", err) + content, marshalErr := json.Marshal(stageResults) + if marshalErr != nil { + slog.Error("marshal stageResults", "error", marshalErr) } - err = os.WriteFile(conf.OutputPath, - append(content, []byte("\n")...), 0o600) - if err != nil { - slog.Error("write stageResults", "error", err) + var outputErr error + if marshalErr == nil { + outputErr = os.WriteFile(conf.OutputPath, + append(content, '\n'), 0o600) + if outputErr != nil { + slog.Error("write stageResults", "error", outputErr) + } } slog.Info("run postStages") - _, _, err = stage.Run(postStages) - if err != nil { - slog.Error("run postStages", "error", err) + _, _, postErr := stage.Run(ctx, postStages) + if postErr != nil { + slog.Error("run postStages", "error", postErr) } - return stageResults, forceQuitStageName, err + return stageResults, forceQuitStageName, errors.Join(preErr, mainErr, marshalErr, outputErr, postErr) } diff --git a/cmd/joj3/stage_test.go b/cmd/joj3/stage_test.go new file mode 100644 index 0000000..9bd4a8f --- /dev/null +++ b/cmd/joj3/stage_test.go @@ -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) + } +} diff --git a/cmd/repo-health-checker/main.go b/cmd/repo-health-checker/main.go index 6108f07..ca29103 100644 --- a/cmd/repo-health-checker/main.go +++ b/cmd/repo-health-checker/main.go @@ -7,6 +7,7 @@ import ( "encoding/json" "flag" "fmt" + "io" "log/slog" "os" @@ -15,8 +16,8 @@ import ( // 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. -func parseMultiValueFlag(values *[]string, flagName, description string) { - flag.Var((*multiStringValue)(values), flagName, description) +func parseMultiValueFlag(flags *flag.FlagSet, values *[]string, flagName, description string) { + flags.Var((*multiStringValue)(values), flagName, description) } type multiStringValue []string @@ -39,36 +40,37 @@ func setupSlog() { slog.SetDefault(logger) } -var ( - rootDir string - repoSize float64 - checkFileNameList string - checkFileSumList string - metaFile []string - whitelistedChars string - allowedDomainList string - actorCsvPath string - showVersion *bool - Version string -) +var Version string -func init() { - showVersion = flag.Bool("version", false, "print current version") - flag.StringVar(&rootDir, "root", ".", "root dir for forbidden files check") - flag.Float64Var(&repoSize, "repoSize", 2, "maximum size of the repo in MiB") - flag.StringVar(&checkFileNameList, "checkFileNameList", "", "comma-separated list of files to check") - flag.StringVar(&checkFileSumList, "checkFileSumList", "", "comma-separated list of expected checksums") - flag.StringVar(&whitelistedChars, "whitelistedChars", "", "comma-separated list of non-ASCII characters allowed in files") - flag.StringVar(&allowedDomainList, "allowedDomainList", "sjtu.edu.cn", "comma-separated list of allowed domains for commit author email") - flag.StringVar(&actorCsvPath, "actorCsvPath", "/home/tt/.config/joj/students.csv", "path to actor csv file") - parseMultiValueFlag(&metaFile, "meta", "meta files to check") -} - -func main() { - flag.Parse() - if *showVersion { - fmt.Println(Version) - return +func run(args []string, stdout io.Writer) error { + flags := flag.NewFlagSet("repo-health-checker", flag.ContinueOnError) + flags.SetOutput(io.Discard) + var ( + rootDir string + repoSize float64 + checkFileNameList string + checkFileSumList string + metaFile []string + whitelistedChars string + allowedDomainList string + actorCsvPath string + showVersion bool + ) + flags.BoolVar(&showVersion, "version", false, "print current version") + flags.StringVar(&rootDir, "root", ".", "root dir for forbidden files check") + flags.Float64Var(&repoSize, "repoSize", 2, "maximum size of the repo in MiB") + flags.StringVar(&checkFileNameList, "checkFileNameList", "", "comma-separated list of files to check") + flags.StringVar(&checkFileSumList, "checkFileSumList", "", "comma-separated list of expected checksums") + flags.StringVar(&whitelistedChars, "whitelistedChars", "", "comma-separated list of non-ASCII characters allowed in files") + flags.StringVar(&allowedDomainList, "allowedDomainList", "sjtu.edu.cn", "comma-separated list of allowed domains for commit author email") + flags.StringVar(&actorCsvPath, "actorCsvPath", "/home/tt/.config/joj/students.csv", "path to actor csv file") + parseMultiValueFlag(flags, &metaFile, "meta", "meta files to check") + if err := flags.Parse(args); err != nil { + return err + } + if showVersion { + _, err := fmt.Fprintln(stdout, Version) + return err } setupSlog() slog.Info("start repo-health-checker", "version", Version) @@ -92,7 +94,15 @@ func main() { jsonRes, err := json.Marshal(res) if err != nil { 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) } - fmt.Println(string(jsonRes)) } diff --git a/cmd/repo-health-checker/main_test.go b/cmd/repo-health-checker/main_test.go new file mode 100644 index 0000000..055bd43 --- /dev/null +++ b/cmd/repo-health-checker/main_test.go @@ -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") + } +} diff --git a/internal/executor/dummy/executor.go b/internal/executor/dummy/executor.go index a31c348..e573d88 100644 --- a/internal/executor/dummy/executor.go +++ b/internal/executor/dummy/executor.go @@ -1,8 +1,12 @@ 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)) for range cmds { res = append(res, stage.ExecutorResult{ @@ -19,6 +23,6 @@ func (e *Dummy) Run(cmds []stage.Cmd) ([]stage.ExecutorResult, error) { return res, nil } -func (e *Dummy) Cleanup() error { +func (e *Dummy) Cleanup(_ context.Context) error { return nil } diff --git a/internal/executor/local/executor.go b/internal/executor/local/executor.go index d91c02e..a6759d4 100644 --- a/internal/executor/local/executor.go +++ b/internal/executor/local/executor.go @@ -2,6 +2,7 @@ package local import ( "bytes" + "context" "fmt" "io" "math" @@ -70,14 +71,12 @@ func (e *Local) generateResult( if err != nil { if exitErr, ok := err.(*exec.ExitError); ok { + result.Status = stage.StatusNonzeroExitStatus status := exitErr.Sys().(syscall.WaitStatus) if status.Signaled() { signal := status.Signal() - switch signal { - case syscall.SIGXCPU: + if signal == syscall.SIGXCPU { result.Status = stage.StatusTimeLimitExceeded - default: - result.Status = stage.StatusNonzeroExitStatus } } result.Error = exitErr.Error() @@ -105,10 +104,16 @@ func (e *Local) generateResult( 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 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.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} if cmd.CPULimit > 0 && cmd.ClockLimit <= 0 { @@ -167,6 +172,10 @@ func (e *Local) Run(cmds []stage.Cmd) ([]stage.ExecutorResult, error) { false, ) results = append(results, result) + case <-ctx.Done(): + _ = syscall.Kill(-execCmd.Process.Pid, syscall.SIGKILL) + <-done + return nil, ctx.Err() case <-time.After(duration): _ = syscall.Kill(-execCmd.Process.Pid, syscall.SIGKILL) err := <-done @@ -216,6 +225,6 @@ func handleCopyOut(result *stage.ExecutorResult, cmd stage.Cmd) error { return nil } -func (e *Local) Cleanup() error { +func (e *Local) Cleanup(_ context.Context) error { return nil } diff --git a/internal/executor/local/executor_test.go b/internal/executor/local/executor_test.go new file mode 100644 index 0000000..578dc84 --- /dev/null +++ b/internal/executor/local/executor_test.go @@ -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) + } +} diff --git a/internal/executor/sandbox/convert.go b/internal/executor/sandbox/convert.go index c907e17..8087dba 100644 --- a/internal/executor/sandbox/convert.go +++ b/internal/executor/sandbox/convert.go @@ -1,7 +1,7 @@ package sandbox import ( - "log/slog" + "fmt" "os" "path/filepath" "strings" @@ -12,14 +12,22 @@ import ( ) // 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)) - 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.SetArgs(c.Args) req.SetEnv(c.Env) req.SetTty(c.TTY) - req.SetFiles(convertPBFiles([]*stage.CmdFile{c.Stdin, c.Stdout, c.Stderr})) + req.SetFiles(files) req.SetCpuTimeLimit(c.CPULimit) req.SetClockTimeLimit(c.ClockLimit) req.SetMemoryLimit(c.MemoryLimit) @@ -29,7 +37,7 @@ func convertPBCmd(cmd []stage.Cmd) []*pb.Request_CmdType { req.SetCpuSetLimit(c.CPUSetLimit) req.SetDataSegmentLimit(c.DataSegmentLimit) req.SetAddressSpaceLimit(c.AddressSpaceLimit) - req.SetCopyIn(convertPBCopyIn(c.CopyIn, c.CopyInDir)) + req.SetCopyIn(copyIn) req.SetCopyOut(convertPBCopyOut(c.CopyOut)) req.SetCopyOutCached(convertPBCopyOut(c.CopyOutCached)) req.SetCopyOutMax(c.CopyOutMax) @@ -37,25 +45,25 @@ func convertPBCmd(cmd []stage.Cmd) []*pb.Request_CmdType { req.SetSymlinks(convertSymlink(c.CopyIn)) ret = append(ret, req) } - return ret + return ret, nil } func convertPBCopyIn( copyIn map[string]stage.CmdFile, copyInDir string, -) map[string]*pb.Request_File { +) (map[string]*pb.Request_File, error) { if copyInDir != "" { - _ = filepath.Walk(copyInDir, + err := filepath.Walk(copyInDir, func(path string, info os.FileInfo, err error) error { if err != nil { - return nil + return err } absPath, err := filepath.Abs(path) if err != nil { - return nil + return err } relPath, err := filepath.Rel(copyInDir, path) if err != nil { - return nil + return err } _, exists := copyIn[relPath] if !info.IsDir() && !exists { @@ -63,15 +71,22 @@ func convertPBCopyIn( } return nil }) + if err != nil { + return nil, fmt.Errorf("walk %q: %w", copyInDir, err) + } } rt := make(map[string]*pb.Request_File, len(copyIn)) for k, i := range copyIn { if i.Symlink != nil { continue } - rt[k] = convertPBFile(i) + file, err := convertPBFile(i) + if err != nil { + return nil, fmt.Errorf("file %q: %w", k, err) + } + rt[k] = file } - return rt + return rt, nil } func convertPBCopyOut(copyOut []string) []*pb.Request_CmdCopyOutFile { @@ -101,65 +116,67 @@ func convertSymlink(copyIn map[string]stage.CmdFile) map[string]string { return ret } -func convertPBFiles(files []*stage.CmdFile) []*pb.Request_File { +func convertPBFiles(files []*stage.CmdFile) ([]*pb.Request_File, error) { var ret []*pb.Request_File for _, f := range files { if f == nil { ret = append(ret, nil) } 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{} switch { case i.Src != nil: if !filepath.IsAbs(*i.Src) { absPath, err := filepath.Abs(*i.Src) if err != nil { - slog.Error("convert pb file get abs path", "path", *i.Src, "error", err) - absPath = "/" + return nil, fmt.Errorf("resolve source path %q: %w", *i.Src, err) } i.Src = &absPath } s, err := os.ReadFile(*i.Src) if err != nil { - s = []byte{} - slog.Error("convert pb file read file", "path", *i.Src, "error", err) + return nil, fmt.Errorf("read source file %q: %w", *i.Src, err) } m := &pb.Request_MemoryFile{} m.SetContent(s) req.SetMemory(m) - return req + return req, nil case i.Content != nil: s := strToBytes(*i.Content) m := &pb.Request_MemoryFile{} m.SetContent(s) req.SetMemory(m) - return req + return req, nil case i.FileID != nil: c := &pb.Request_CachedFile{} c.SetFileID(*i.FileID) req.SetCached(c) - return req + return req, nil case i.Name != nil && i.Max != nil: p := &pb.Request_PipeCollector{} p.SetName(*i.Name) p.SetMax(*i.Max) p.SetPipe(i.Pipe) req.SetPipe(p) - return req + return req, nil case i.StreamIn: req.SetStreamIn(&emptypb.Empty{}) - return req + return req, nil case i.StreamOut: req.SetStreamOut(&emptypb.Empty{}) - return req + return req, nil } - return nil + return nil, nil } func convertPBResult(res []*pb.Response_Result) []stage.ExecutorResult { diff --git a/internal/executor/sandbox/convert_test.go b/internal/executor/sandbox/convert_test.go new file mode 100644 index 0000000..5bc2491 --- /dev/null +++ b/internal/executor/sandbox/convert_test.go @@ -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) + } +} diff --git a/internal/executor/sandbox/executor.go b/internal/executor/sandbox/executor.go index 987382f..c037bec 100644 --- a/internal/executor/sandbox/executor.go +++ b/internal/executor/sandbox/executor.go @@ -2,20 +2,23 @@ package sandbox import ( "context" + "errors" "fmt" "log/slog" "maps" + "math" + "time" "github.com/criyle/go-judge/pb" "github.com/joint-online-judge/JOJ3/internal/stage" "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 if e.execClient == nil { 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 { 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 { slog.Debug("sandbox execute", "i", i, "pbCmd size", proto.Size(pbCmd)) } pbReq := &pb.Request{} pbReq.SetCmd(pbCmds) 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 { return nil, err } @@ -53,15 +61,50 @@ func (e *Sandbox) Run(cmds []stage.Cmd) ([]stage.ExecutorResult, error) { 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 { req := &pb.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 { slog.Error("sandbox cleanup", "error", err) + cleanupErr = errors.Join(cleanupErr, err) } 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 } diff --git a/internal/executor/sandbox/executor_test.go b/internal/executor/sandbox/executor_test.go new file mode 100644 index 0000000..eab6285 --- /dev/null +++ b/internal/executor/sandbox/executor_test.go @@ -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) + } +} diff --git a/internal/executor/sandbox/grpc.go b/internal/executor/sandbox/grpc.go index 6db82b0..fa841b8 100644 --- a/internal/executor/sandbox/grpc.go +++ b/internal/executor/sandbox/grpc.go @@ -12,13 +12,13 @@ import ( ) // 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) if err != nil { 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) { diff --git a/internal/executor/sandbox/grpc_test.go b/internal/executor/sandbox/grpc_test.go new file mode 100644 index 0000000..9562604 --- /dev/null +++ b/internal/executor/sandbox/grpc_test.go @@ -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) + } +} diff --git a/internal/executor/sandbox/meta.go b/internal/executor/sandbox/meta.go index 0a72560..0fe91c8 100644 --- a/internal/executor/sandbox/meta.go +++ b/internal/executor/sandbox/meta.go @@ -5,8 +5,11 @@ package sandbox import ( + "time" + "github.com/criyle/go-judge/pb" "github.com/joint-online-judge/JOJ3/internal/stage" + "google.golang.org/grpc" ) var name = "sandbox" @@ -15,8 +18,11 @@ type Sandbox struct { execServer, token string cachedMap map[string]string execClient pb.ExecutorClient + conn *grpc.ClientConn } +const rpcTimeoutMargin = 30 * time.Second + func init() { stage.RegisterExecutor(name, &Sandbox{ execServer: "localhost:5051", diff --git a/internal/parser/keyword/parser_test.go b/internal/parser/keyword/parser_test.go new file mode 100644 index 0000000..089d5df --- /dev/null +++ b/internal/parser/keyword/parser_test.go @@ -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) + } +} diff --git a/internal/stage/executor.go b/internal/stage/executor.go index 8b54854..48a1007 100644 --- a/internal/stage/executor.go +++ b/internal/stage/executor.go @@ -1,6 +1,7 @@ package stage import ( + "context" "encoding/json" "fmt" "strconv" @@ -9,8 +10,8 @@ import ( var executorMap = map[string]Executor{} type Executor interface { - Run([]Cmd) ([]ExecutorResult, error) - Cleanup() error + Run(context.Context, []Cmd) ([]ExecutorResult, error) + Cleanup(context.Context) error } func RegisterExecutor(name string, executor Executor) { diff --git a/internal/stage/fileerror.go b/internal/stage/fileerror.go index 5bfed66..379a37d 100644 --- a/internal/stage/fileerror.go +++ b/internal/stage/fileerror.go @@ -60,7 +60,7 @@ func (t FileErrorType) MarshalJSON() ([]byte, error) { func (t *FileErrorType) UnmarshalJSON(b []byte) error { str := string(b) v, ok := fileErrorStringReverse[str] - if ok { + if !ok { return fmt.Errorf("%s is not file error type", str) } *t = v diff --git a/internal/stage/json_test.go b/internal/stage/json_test.go new file mode 100644 index 0000000..f33c888 --- /dev/null +++ b/internal/stage/json_test.go @@ -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) + } +} diff --git a/internal/stage/run.go b/internal/stage/run.go index 418154d..4d058fa 100644 --- a/internal/stage/run.go +++ b/internal/stage/run.go @@ -4,11 +4,13 @@ package stage import ( + "context" + "errors" "fmt" "log/slog" ) -func Run(stages []Stage) ( +func Run(ctx context.Context, stages []Stage) ( stageResults []StageResult, forceQuitStageName string, err error, ) { var executorResults []ExecutorResult @@ -59,9 +61,10 @@ func Run(stages []Stage) ( "name", stage.Executor.Name, ) err = fmt.Errorf("executor not found: %s", stage.Executor.Name) + forceQuitStageName = stage.Name return } - executorResults, err = executor.Run(stage.Executor.Cmds) + executorResults, err = executor.Run(ctx, stage.Executor.Cmds) if err != nil { slog.Error( "executor run error", @@ -69,6 +72,7 @@ func Run(stages []Stage) ( "name", stage.Executor.Name, "error", err, ) + forceQuitStageName = stage.Name return } for i, executorResult := range executorResults { @@ -115,6 +119,7 @@ func Run(stages []Stage) ( "name", stageParser.Name, ) err = fmt.Errorf("parser not found: %s", stageParser.Name) + forceQuitStageName = stage.Name return } var parserForceQuit bool @@ -130,6 +135,14 @@ func Run(stages []Stage) ( forceQuitStageName = stage.Name 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 { parserScoresMap[stageParser.Name][i] += parserResult.Score } @@ -185,12 +198,15 @@ func Run(stages []Stage) ( return stageResults, forceQuitStageName, err } -func Cleanup() { +func Cleanup(ctx context.Context) error { slog.Info("stage cleanup start") + var cleanupErr error for name, executor := range executorMap { - err := executor.Cleanup() + err := executor.Cleanup(ctx) if err != nil { slog.Error("executor cleanup error", "name", name, "error", err) + cleanupErr = errors.Join(cleanupErr, fmt.Errorf("executor %q cleanup: %w", name, err)) } } + return cleanupErr } diff --git a/internal/stage/run_test.go b/internal/stage/run_test.go new file mode 100644 index 0000000..cfed0f7 --- /dev/null +++ b/internal/stage/run_test.go @@ -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) + } + }) + } +} diff --git a/pkg/healthcheck/author_test.go b/pkg/healthcheck/author_test.go new file mode 100644 index 0000000..5a80905 --- /dev/null +++ b/pkg/healthcheck/author_test.go @@ -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) + } +} diff --git a/pkg/healthcheck/files_test.go b/pkg/healthcheck/files_test.go new file mode 100644 index 0000000..ce429e5 --- /dev/null +++ b/pkg/healthcheck/files_test.go @@ -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) + } +}