package main import ( "bufio" "context" "encoding/json" "errors" "fmt" "io" "os" "os/exec" "path/filepath" "regexp" "sort" "strings" "sync" "time" "git.eidonix.com/eidonix/cletus-plugins/sdk/manifest" ) // report collects the outcome of each check. type report struct { failures, warnings int } func (r *report) ok(name string) { fmt.Printf("ok %s\n", name) } func (r *report) fail(name, detail string) { r.failures++ fmt.Printf("FAIL %s\n%s", name, indent(detail)) } func (r *report) warn(name, detail string) { r.warnings++ fmt.Printf("warn %s\n%s", name, indent(detail)) } func indent(s string) string { s = strings.TrimRight(s, "\n") if s == "" { return "" } return " " + strings.ReplaceAll(s, "\n", "\n ") + "\n" } func runCheck(args []string) error { if len(args) > 0 { return errors.New("check takes no arguments; run it in the plugin's folder") } r := &report{} plugin, err := readPlugin(".") if err != nil { return err } if errs := plugin.Validate(); len(errs) > 0 { var b strings.Builder for _, e := range errs { fmt.Fprintln(&b, e) } r.fail("plugin.json", b.String()) } else { r.ok("plugin.json") } checkFiles(r, plugin) own := ownGoFiles() checkSources(r, own) checkGoMod(r) if out, err := goRun(".", goEnv("", ""), "vet", "./..."); err != nil { r.fail("go vet", out) } else { r.ok("go vet") } checkImports(r, plugin) checkDependencies(r) for _, target := range [][2]string{{"darwin", "arm64"}, {"windows", "amd64"}} { name := "build " + target[0] + "/" + target[1] if out, err := goRun(".", goEnv(target[0], target[1]), "build", "-trimpath", "-o", os.DevNull, "./cmd/worker"); err != nil { r.fail(name, out) } else { r.ok(name) } } if out, err := goRun(".", goEnv("", ""), "test", "-count=1", "./..."); err != nil { r.fail("go test", out) } else { r.ok("go test") } optionalTool(r, "staticcheck", "./...") optionalTool(r, "govulncheck", "./...") checkRuntime(r, plugin) fmt.Println() switch { case r.failures > 0: return fmt.Errorf("%d check(s) failed, %d warning(s)", r.failures, r.warnings) case r.warnings > 0: fmt.Printf("All checks passed with %d warning(s). Explain each in docs/plan.md; reviewers read them.\n", r.warnings) default: fmt.Println("All checks passed.") } return nil } func checkFiles(r *report, plugin *manifest.Plugin) { var problems []string changelog, err := os.ReadFile("CHANGELOG.md") if err != nil { problems = append(problems, "CHANGELOG.md is missing") } else if !regexp.MustCompile(`(?m)^#+\s*v?` + regexp.QuoteMeta(plugin.Version) + `\b`).Match(changelog) { problems = append(problems, "CHANGELOG.md has no heading for version "+plugin.Version) } for _, path := range []string{"docs/plan.md", "docs/guide.md", "go.mod", "cmd/worker"} { if _, err := os.Stat(path); err != nil { problems = append(problems, path+" is missing") } } if plan, err := os.ReadFile("docs/plan.md"); err == nil { for _, perm := range plugin.Permissions { if !strings.Contains(string(plan), perm) { problems = append(problems, "docs/plan.md doesn't explain the "+perm+" permission") } } } if len(problems) > 0 { r.fail("repository files", strings.Join(problems, "\n")) } else { r.ok("repository files") } } // ownGoFiles lists the plugin's own Go files (not vendor/, not hidden folders). func ownGoFiles() []string { var files []string _ = filepath.WalkDir(".", func(path string, d os.DirEntry, err error) error { if err != nil { return nil } if d.IsDir() && path != "." && (d.Name() == "vendor" || strings.HasPrefix(d.Name(), ".") || d.Name() == "node_modules") { return filepath.SkipDir } if !d.IsDir() && strings.HasSuffix(path, ".go") { files = append(files, path) } return nil }) return files } var ( secretRE = regexp.MustCompile(`-----BEGIN [A-Z ]*PRIVATE KEY-----|AKIA[0-9A-Z]{16}|\bsk-[A-Za-z0-9_-]{20,}|\bghp_[A-Za-z0-9]{30,}|\bxox[abp]-[A-Za-z0-9-]{10,}`) fileOpRE = regexp.MustCompile(`\bos\.(Open|OpenFile|ReadFile|WriteFile|Create|Remove|RemoveAll|ReadDir|Rename|Mkdir|MkdirAll)\(`) linknameRE = regexp.MustCompile(`//go:linkname`) ) func checkSources(r *report, files []string) { var gofmtBad, cgo, secrets, linkname, fileOps []string if len(files) > 0 { out, _ := exec.Command("gofmt", append([]string{"-l"}, files...)...).Output() for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { if line != "" { gofmtBad = append(gofmtBad, line) } } } _ = filepath.WalkDir(".", func(path string, d os.DirEntry, err error) error { if err != nil || d.IsDir() { if d != nil && d.IsDir() && path != "." && strings.HasPrefix(d.Name(), ".") { return filepath.SkipDir } return nil } info, err := d.Info() if err != nil || info.Size() > 4<<20 { return nil } data, err := os.ReadFile(path) if err != nil { return nil } text := string(data) vendored := strings.HasPrefix(filepath.ToSlash(path), "vendor/") if strings.HasSuffix(path, ".go") && regexp.MustCompile(`(?m)^import\s+"C"`).MatchString(text) { cgo = append(cgo, path) } if !vendored && secretRE.MatchString(text) { secrets = append(secrets, path) } if strings.HasSuffix(path, ".go") && !vendored { if linknameRE.MatchString(text) { linkname = append(linkname, path) } if !strings.HasSuffix(path, "_test.go") && fileOpRE.MatchString(text) { fileOps = append(fileOps, path) } } return nil }) if len(gofmtBad) > 0 { r.fail("gofmt", "not formatted (run gofmt -w):\n"+strings.Join(gofmtBad, "\n")) } else { r.ok("gofmt") } if len(cgo) > 0 { r.fail("no cgo", "these files use cgo, which Cletus plugins can't:\n"+strings.Join(cgo, "\n")) } else { r.ok("no cgo") } if len(secrets) > 0 { r.fail("no secrets", "these files look like they hold a key or token:\n"+strings.Join(secrets, "\n")) } else { r.ok("no secrets") } if len(linkname) > 0 { r.fail("no go:linkname", strings.Join(linkname, "\n")) } if len(fileOps) > 0 { r.warn("direct file access", "these files open, write or delete files directly. Each path must come from\nhost.files.resolve, a dialog, or pluginsdk.DataDir(); anything else needs files.computer:\n"+strings.Join(fileOps, "\n")) } } func checkGoMod(r *report) { data, err := os.ReadFile("go.mod") if err != nil { return } text := string(data) var problems []string if regexp.MustCompile(`(?m)^\s*replace\b|=>`).MatchString(text) { problems = append(problems, "go.mod has replace directives; the store builds only from go.mod and vendor/") } if strings.Contains(text, "require") { if _, err := os.Stat("vendor/modules.txt"); err != nil { problems = append(problems, "vendor/ is missing; run go mod vendor and commit it") } else if out, err := goRun(".", goEnv("", ""), "build", "-mod=vendor", "-o", os.DevNull, "./cmd/worker"); err != nil { problems = append(problems, "vendor/ doesn't match go.mod; run go mod vendor:\n"+out) } } if len(problems) > 0 { r.fail("go.mod and vendor", strings.Join(problems, "\n")) } else { r.ok("go.mod and vendor") } } type goPackage struct { ImportPath string Imports []string Standard bool Module *struct{ Path string } } func listPackages(args ...string) ([]goPackage, error) { cmd := exec.Command("go", append([]string{"list", "-json"}, args...)...) cmd.Env = goEnv("", "") out, err := cmd.Output() if err != nil { var exit *exec.ExitError if errors.As(err, &exit) { return nil, fmt.Errorf("%s", exit.Stderr) } return nil, err } var pkgs []goPackage dec := json.NewDecoder(strings.NewReader(string(out))) for { var p goPackage if err := dec.Decode(&p); err == io.EOF { break } else if err != nil { return nil, err } pkgs = append(pkgs, p) } return pkgs, nil } var netPackages = map[string]bool{"net": true, "net/http": true, "net/rpc": true, "net/smtp": true, "crypto/tls": true, "net/http/httputil": true} func checkImports(r *report, plugin *manifest.Plugin) { own, err := listPackages("./...") if err != nil { r.fail("imports", err.Error()) return } var failures, notes []string for _, p := range own { for _, imp := range p.Imports { switch { case imp == "plugin": failures = append(failures, p.ImportPath+" imports plugin (loading code at run time is not allowed)") case imp == "os/exec" && !plugin.Has("processes"): failures = append(failures, p.ImportPath+" imports os/exec but the plugin doesn't declare processes") case netPackages[imp] && !plugin.HasNet(): failures = append(failures, p.ImportPath+" imports "+imp+" but the plugin declares no net permission") case imp == "unsafe" || imp == "reflect" || imp == "syscall" || strings.HasPrefix(imp, "golang.org/x/sys/"): notes = append(notes, p.ImportPath+" imports "+imp) } } } if len(failures) > 0 { r.fail("imports match permissions", strings.Join(failures, "\n")) } else { r.ok("imports match permissions") } if len(notes) > 0 { r.warn("low-level imports", "explain each in docs/plan.md:\n"+strings.Join(notes, "\n")) } deps, err := listPackages("-deps", "./cmd/worker") if err != nil { return } var depNotes []string for _, p := range deps { if p.Standard || p.Module == nil || isOwnModule(p.Module.Path) { continue } for _, imp := range p.Imports { if imp == "os/exec" && !plugin.Has("processes") || netPackages[imp] && !plugin.HasNet() { depNotes = append(depNotes, p.ImportPath+" imports "+imp) } } } if len(depNotes) > 0 { sort.Strings(depNotes) r.warn("dependencies that can start programs or connect", "your plugin doesn't declare this; make sure these code paths are never used:\n"+strings.Join(depNotes, "\n")) } } func isOwnModule(path string) bool { data, err := os.ReadFile("go.mod") if err != nil { return false } m := regexp.MustCompile(`(?m)^module\s+(\S+)`).FindStringSubmatch(string(data)) return len(m) == 2 && m[1] == path } func checkDependencies(r *report) { f, err := os.Open("vendor/modules.txt") if err != nil { return } defer f.Close() var missing []string sc := bufio.NewScanner(f) for sc.Scan() { fields := strings.Fields(sc.Text()) if len(fields) < 3 || fields[0] != "#" || fields[2] == "=>" { continue } dir := filepath.Join("vendor", filepath.FromSlash(fields[1])) if _, err := os.Stat(dir); err != nil { continue // a module with no packages in the build } if !hasLicense(dir) { missing = append(missing, fields[1]+" "+fields[2]) } } if len(missing) > 0 { r.warn("dependency licences", "no licence file found; the store must be able to confirm each licence allows distribution:\n"+strings.Join(missing, "\n")) } else { r.ok("dependency licences present") } } func hasLicense(dir string) bool { entries, err := os.ReadDir(dir) if err != nil { return false } for _, e := range entries { name := strings.ToUpper(e.Name()) if strings.HasPrefix(name, "LICENSE") || strings.HasPrefix(name, "LICENCE") || strings.HasPrefix(name, "COPYING") { return true } } return false } func optionalTool(r *report, tool string, args ...string) { if _, err := exec.LookPath(tool); err != nil { fmt.Printf("skip %s (not installed; the store runs it)\n", tool) return } cmd := exec.Command(tool, args...) cmd.Env = goEnv("", "") out, err := cmd.CombinedOutput() if err != nil { r.fail(tool, string(out)) } else { r.ok(tool) } } // checkRuntime starts the worker and compares its tools with plugin.json. func checkRuntime(r *report, plugin *manifest.Plugin) { wd, _ := os.Getwd() bin, cleanup, err := buildWorker(wd) if err != nil { r.fail("worker starts", err.Error()) return } defer cleanup() tmp, err := os.MkdirTemp("", "cletus-plugin-check-") if err != nil { r.fail("worker starts", err.Error()) return } defer os.RemoveAll(tmp) log := &syncBuffer{} host := &testHost{plugin: plugin, filesDir: tmp, dataDir: filepath.Join(tmp, "data"), capabilities: map[string]bool{}, log: log} if err := host.start(bin); err != nil { r.fail("worker starts", err.Error()+"\n"+log.String()) return } defer host.stop() r.ok("worker starts and answers the handshake") ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() var tools []struct { Def struct { Function struct { Name string `json:"name"` Description string `json:"description"` } `json:"function"` } `json:"def"` Mutating bool `json:"mutating"` RequiresApproval bool `json:"requires_approval"` } if err := host.call(ctx, "tools.list", nil, &tools); err != nil { r.fail("tools match plugin.json", "tools.list: "+err.Error()) return } declared := map[string]manifest.Tool{} for _, t := range plugin.Tools { declared[t.Name] = t } var problems []string for _, t := range tools { name := t.Def.Function.Name d, ok := declared[name] switch { case !ok: problems = append(problems, "the worker offers "+name+", which plugin.json doesn't list") case d.Mutating != t.Mutating || d.RequiresApproval != t.RequiresApproval: problems = append(problems, name+": mutating or requires_approval differs between plugin.json and the worker") } if strings.TrimSpace(t.Def.Function.Description) == "" { problems = append(problems, name+" has no description") } delete(declared, name) } for name := range declared { problems = append(problems, "plugin.json lists "+name+", which the worker doesn't offer") } if len(problems) > 0 { sort.Strings(problems) r.fail("tools match plugin.json", strings.Join(problems, "\n")) } else { r.ok("tools match plugin.json") } } // syncBuffer is a strings.Builder safe for the worker's log and the host's // call log to write at once. type syncBuffer struct { mu sync.Mutex b strings.Builder } func (s *syncBuffer) Write(p []byte) (int, error) { s.mu.Lock() defer s.mu.Unlock() return s.b.Write(p) } func (s *syncBuffer) String() string { s.mu.Lock() defer s.mu.Unlock() return s.b.String() }