package kiosk import ( "bytes" "context" "encoding/json" "errors" "fmt" "go/ast" "go/parser" "go/token" "go/types" "os" "path/filepath" "regexp" "slices" "strconv" "strings" ) // Identity is what a plugin's ID() and Requires() return. type Identity struct { ID string Requires []string } // ReadIdentity reads the plugin ID and the required plugin IDs from the // Go sources in dir without building or running them. ID() and Requires() // may return string literals or constants: a const of the same package, of // another package of the same module, or of a package in a module go.mod // requires (located with `go list -m -json`). Requires() may also return // nil or an empty slice. Any other expression is an error naming ID() or // Requires(). When only Requires() fails, the returned Identity still // carries the ID. func ReadIdentity(ctx context.Context, dir string) (Identity, error) { abs, err := filepath.Abs(dir) if err != nil { return Identity{}, fmt.Errorf("kiosk: resolve %s: %w", dir, err) } r := newResolver(ctx, abs) return r.identity(abs) } // resolver evaluates string constants across packages and modules. Module // facts are loaded on first use, so a plugin whose ID is a literal costs // no go command. type resolver struct { ctx context.Context start string fset *token.FileSet pkgs map[string]*pkgSrc active map[string]bool modLoaded bool modErr error root string mod goMod modDirs map[string]listedModule } // pkgSrc is one parsed package directory. type pkgSrc struct { name string files map[string]*ast.File order []string consts map[string]constDef } type constDef struct { expr ast.Expr file *ast.File } // listedModule is the part of `go list -m -json` output the resolver reads. type listedModule struct { Path string Version string Dir string GoMod string Error *struct{ Err string } } func newResolver(ctx context.Context, start string) *resolver { return &resolver{ ctx: ctx, start: start, fset: token.NewFileSet(), pkgs: map[string]*pkgSrc{}, active: map[string]bool{}, modDirs: map[string]listedModule{}, } } func (r *resolver) identity(dir string) (Identity, error) { pkg, err := r.load(dir) if err != nil { return Identity{}, err } idFn, idFile, idPath := pkg.method("ID", "") if idFn == nil { return Identity{}, fmt.Errorf("kiosk: %s has no ID() method", dir) } var id Identity expr := lastReturn(idFn) if expr == nil { return Identity{}, fmt.Errorf("kiosk: %s: ID() must return a string literal or constant", idPath) } id.ID, err = r.str(dir, idFile, expr) if err != nil { return Identity{}, fmt.Errorf("kiosk: %s: ID() must return a string literal or constant: %w", idPath, err) } if id.ID == "" { return Identity{}, fmt.Errorf("kiosk: %s: ID() returns an empty string", idPath) } reqFn, reqFile, reqPath := pkg.method("Requires", recvType(idFn)) if reqFn == nil { return id, nil } reqs, err := r.requires(dir, reqFile, lastReturn(reqFn)) if err != nil { return id, fmt.Errorf("kiosk: %s: Requires() must return nil or a []string of string literals or constants: %w", reqPath, err) } id.Requires = reqs return id, nil } func (r *resolver) requires(dir string, file *ast.File, expr ast.Expr) ([]string, error) { switch e := expr.(type) { case nil: return nil, errors.New("no single-value return") case *ast.Ident: if e.Name == "nil" { return nil, nil } case *ast.CompositeLit: if at, ok := e.Type.(*ast.ArrayType); !ok || at.Len != nil || types.ExprString(at.Elt) != "string" { break } out := make([]string, 0, len(e.Elts)) for _, el := range e.Elts { s, err := r.str(dir, file, el) if err != nil { return nil, err } out = append(out, s) } return out, nil } return nil, fmt.Errorf("%s is not a []string literal", types.ExprString(expr)) } // str evaluates expr, found in file of package dir, to a string constant. func (r *resolver) str(dir string, file *ast.File, expr ast.Expr) (string, error) { switch e := expr.(type) { case *ast.ParenExpr: return r.str(dir, file, e.X) case *ast.BasicLit: if e.Kind == token.STRING { return strconv.Unquote(e.Value) } case *ast.Ident: return r.constant(dir, e.Name) case *ast.SelectorExpr: if x, ok := e.X.(*ast.Ident); ok { pkgDir, err := r.importDir(dir, file, x.Name) if err != nil { return "", err } return r.constant(pkgDir, e.Sel.Name) } case *ast.BinaryExpr: if e.Op == token.ADD { a, err := r.str(dir, file, e.X) if err != nil { return "", err } b, err := r.str(dir, file, e.Y) if err != nil { return "", err } return a + b, nil } } return "", fmt.Errorf("%s is not a string constant", types.ExprString(expr)) } // constant resolves the package-level const name of package dir. func (r *resolver) constant(dir, name string) (string, error) { key := dir + "\x00" + name if r.active[key] { return "", fmt.Errorf("constant %s refers to itself", name) } r.active[key] = true defer delete(r.active, key) pkg, err := r.load(dir) if err != nil { return "", err } def, ok := pkg.consts[name] if !ok { return "", fmt.Errorf("%s is not a constant of package %s", name, pkg.name) } if def.expr == nil { return "", fmt.Errorf("constant %s has no explicit value", name) } return r.str(dir, def.file, def.expr) } // load parses the non-test Go files of dir once. func (r *resolver) load(dir string) (*pkgSrc, error) { if p, ok := r.pkgs[dir]; ok { return p, nil } entries, err := os.ReadDir(dir) if err != nil { return nil, fmt.Errorf("kiosk: read %s: %w", dir, err) } p := &pkgSrc{files: map[string]*ast.File{}, consts: map[string]constDef{}} for _, e := range entries { n := e.Name() if e.IsDir() || !strings.HasSuffix(n, ".go") || strings.HasSuffix(n, "_test.go") { continue } path := filepath.Join(dir, n) f, err := parser.ParseFile(r.fset, path, nil, parser.SkipObjectResolution) if err != nil { return nil, fmt.Errorf("kiosk: parse %s: %w", path, err) } if p.name == "" { p.name = f.Name.Name } p.files[path] = f p.order = append(p.order, path) for _, decl := range f.Decls { gd, ok := decl.(*ast.GenDecl) if !ok || gd.Tok != token.CONST { continue } for _, spec := range gd.Specs { vs := spec.(*ast.ValueSpec) for i, name := range vs.Names { def := constDef{file: f} if i < len(vs.Values) { def.expr = vs.Values[i] } p.consts[name.Name] = def } } } } if len(p.order) == 0 { return nil, fmt.Errorf("kiosk: %s has no Go files", dir) } // plugin.go first, then the other files in name order. slices.SortStableFunc(p.order, func(a, b string) int { ap, bp := filepath.Base(a) == "plugin.go", filepath.Base(b) == "plugin.go" switch { case ap && !bp: return -1 case bp && !ap: return 1 } return strings.Compare(a, b) }) r.pkgs[dir] = p return p, nil } // method finds a method with a body named name, on the receiver type recv // when recv is not empty, preferring plugin.go. func (p *pkgSrc) method(name, recv string) (*ast.FuncDecl, *ast.File, string) { for _, path := range p.order { f := p.files[path] for _, decl := range f.Decls { fn, ok := decl.(*ast.FuncDecl) if !ok || fn.Recv == nil || fn.Body == nil || fn.Name.Name != name { continue } if recv != "" && recvType(fn) != recv { continue } return fn, f, path } } return nil, nil, "" } // recvType is the receiver's type name without pointer or type parameters. func recvType(fn *ast.FuncDecl) string { if fn.Recv == nil || len(fn.Recv.List) == 0 { return "" } t := fn.Recv.List[0].Type if s, ok := t.(*ast.StarExpr); ok { t = s.X } switch x := t.(type) { case *ast.IndexExpr: t = x.X case *ast.IndexListExpr: t = x.X } if id, ok := t.(*ast.Ident); ok { return id.Name } return "" } var majorSuffix = regexp.MustCompile(`^v[0-9]+$`) // importDir finds the directory of the package file imports as pkgName. func (r *resolver) importDir(dir string, file *ast.File, pkgName string) (string, error) { var unnamed []string for _, imp := range file.Imports { path, err := strconv.Unquote(imp.Path.Value) if err != nil { continue } if imp.Name != nil { if imp.Name.Name == pkgName { return r.dirForImport(path) } continue } unnamed = append(unnamed, path) } // An unnamed import is usually named after its last path element. for _, path := range unnamed { if lastElem(path) == pkgName { if d, err := r.dirForImport(path); err == nil { if p, err := r.load(d); err == nil && p.name == pkgName { return d, nil } } } } // Otherwise read the package clause of every non-standard import. for _, path := range unnamed { first, _, _ := strings.Cut(path, "/") if !strings.Contains(first, ".") || lastElem(path) == pkgName { continue } d, err := r.dirForImport(path) if err != nil { continue } if p, err := r.load(d); err == nil && p.name == pkgName { return d, nil } } return "", fmt.Errorf("%s: no import provides package %s", r.fset.Position(file.Package).Filename, pkgName) } func lastElem(path string) string { parts := strings.Split(path, "/") last := parts[len(parts)-1] if majorSuffix.MatchString(last) && len(parts) > 1 { last = parts[len(parts)-2] } return strings.TrimPrefix(last, "go-") } // loadModule reads the module that contains the start directory. func (r *resolver) loadModule() error { if r.modLoaded { return r.modErr } r.modLoaded = true r.root, r.modErr = moduleRoot(r.start) if r.modErr == nil { r.mod, r.modErr = readGoMod(r.ctx, r.root) } return r.modErr } // dirForImport maps an import path to a directory: inside this module, or // inside a module go.mod requires. func (r *resolver) dirForImport(path string) (string, error) { if err := r.loadModule(); err != nil { return "", err } if rest, ok := cutModule(path, r.mod.Module.Path); ok { return filepath.Join(r.root, filepath.FromSlash(rest)), nil } best := "" for _, req := range r.mod.Require { if _, ok := cutModule(path, req.Path); ok && len(req.Path) > len(best) { best = req.Path } } if best == "" { return "", fmt.Errorf("import %s is neither in module %s nor in a module its go.mod requires", path, r.mod.Module.Path) } mods, err := r.modules(best) if err != nil { return "", err } m := mods[0] if m.Dir == "" { return "", fmt.Errorf("module %s has no source directory", best) } rest, _ := cutModule(path, best) return filepath.Join(m.Dir, filepath.FromSlash(rest)), nil } // cutModule reports whether path is mod or inside it, and the rest. func cutModule(path, mod string) (string, bool) { if path == mod { return "", true } if rest, ok := strings.CutPrefix(path, mod+"/"); ok { return rest, true } return "", false } // modules lists the given required modules with `go list -m -e -json` // and downloads the ones whose source is not in the module cache yet. func (r *resolver) modules(paths ...string) ([]listedModule, error) { res, err := r.list(paths) if err != nil { return nil, err } return res, r.download(res) } // list runs one `go list -m -e -json` for the paths not listed yet. func (r *resolver) list(paths []string) ([]listedModule, error) { if err := r.loadModule(); err != nil { return nil, err } var missing []string for _, p := range paths { if _, ok := r.modDirs[p]; !ok { missing = append(missing, p) } } if len(missing) > 0 { args := append([]string{"list", "-m", "-e", "-json", "--"}, missing...) out, err := runGo(r.ctx, r.root, args...) if err != nil { return nil, err } dec := json.NewDecoder(bytes.NewReader(out)) for dec.More() { var m listedModule if err := dec.Decode(&m); err != nil { return nil, fmt.Errorf("kiosk: go list -m: %w", err) } r.modDirs[m.Path] = m } } res := make([]listedModule, 0, len(paths)) for _, p := range paths { m := r.modDirs[p] if m.Path == "" { m.Path = p } res = append(res, m) } return res, nil } // download fills Dir for listed modules whose source is not in the module // cache, with one `go mod download -json` run. func (r *resolver) download(mods []listedModule) error { var args []string for _, m := range mods { if m.Dir == "" && m.Error == nil && m.Version != "" { args = append(args, m.Path+"@"+m.Version) } } if len(args) == 0 { return nil } out, err := runGo(r.ctx, r.root, append([]string{"mod", "download", "-json", "--"}, args...)...) if err != nil { return err } dec := json.NewDecoder(bytes.NewReader(out)) for dec.More() { var d listedModule if err := dec.Decode(&d); err != nil { return fmt.Errorf("kiosk: go mod download: %w", err) } if m, ok := r.modDirs[d.Path]; ok && d.Dir != "" { m.Dir = d.Dir r.modDirs[d.Path] = m } } for i := range mods { mods[i] = r.modDirs[mods[i].Path] } return nil } // minimums maps each required plugin ID to its minimum version. A plugin // of the same module gets "", one found in a go.mod-required module gets // that require's version, and one found nowhere gets "". func (r *resolver) minimums(ids []string) (Requires, []string, error) { if len(ids) == 0 { return nil, nil, nil } if err := r.loadModule(); err != nil { return nil, nil, err } own := map[string]bool{} for _, d := range pluginDirs(r.root) { if id, _ := r.identity(d); id.ID != "" { own[id.ID] = true } } provider := map[string]string{} pending := 0 for _, id := range ids { if !own[id] { pending++ } } var warnings []string if pending > 0 { var paths []string for _, req := range r.mod.Require { if req.Path != FrameworkModule { paths = append(paths, req.Path) } } listed, err := r.list(paths) if err != nil { return nil, nil, err } // Only a module whose go.mod requires the framework can hold a plugin. var candidates []listedModule for _, m := range listed { if m.Error == nil && requiresFramework(m) { candidates = append(candidates, m) } } if err := r.download(candidates); err != nil { warnings = append(warnings, fmt.Sprintf("required plugins not matched to modules: %v", err)) } for _, m := range candidates { m = r.modDirs[m.Path] if m.Dir == "" { continue } for _, d := range pluginDirs(m.Dir) { if id, _ := ReadIdentity(r.ctx, d); id.ID != "" { if _, seen := provider[id.ID]; !seen { provider[id.ID] = m.Path } } } } } out := Requires{} for _, id := range ids { if own[id] { out[id] = "" continue } modPath, ok := provider[id] if !ok { out[id] = "" continue } v, _ := r.mod.version(modPath) switch { case v == "v0.0.0": return nil, warnings, fmt.Errorf("kiosk: %s requires %s at v0.0.0 (module of %s), which is not a released version; require a tag", filepath.Join(r.root, "go.mod"), modPath, id) case isPseudoVersion(v): warnings = append(warnings, fmt.Sprintf("%s minimum %s is a pseudo-version; require a tag of %s before publishing", id, v, modPath)) } out[id] = v } return out, warnings, nil } // requiresFramework reports whether the module's go.mod names the // framework module. func requiresFramework(m listedModule) bool { path := m.GoMod if path == "" && m.Dir != "" { path = filepath.Join(m.Dir, "go.mod") } if path == "" { return false } data, err := os.ReadFile(path) return err == nil && bytes.Contains(data, []byte(FrameworkModule)) } // pluginDirs lists the directories under root that hold a plugin.go, // skipping hidden, testdata, vendor and node_modules directories and // nested modules. func pluginDirs(root string) []string { var dirs []string _ = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { if err != nil { return nil } if d.IsDir() { if path == root { return nil } n := d.Name() if strings.HasPrefix(n, ".") || strings.HasPrefix(n, "_") || n == "testdata" || n == "vendor" || n == "node_modules" { return filepath.SkipDir } if _, err := os.Stat(filepath.Join(path, "go.mod")); err == nil { return filepath.SkipDir } return nil } if d.Name() == "plugin.go" { dirs = append(dirs, filepath.Dir(path)) } return nil }) return dirs }