Files
bjarneo--cliamp/pluginmgr/pluginmgr.go
T
wehub-resource-sync ead81af521
Deploy to GitHub Pages / deploy (push) Failing after 0s
CI / go (push) Has been cancelled
CI / build (darwin, macos-14) (push) Has been cancelled
CI / build (windows, windows-2025) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:31:13 +08:00

402 lines
11 KiB
Go

// Package pluginmgr implements the `cliamp plugins` CLI subcommands:
// list, install, and remove.
package pluginmgr
import (
"bufio"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
lua "github.com/yuin/gopher-lua"
"github.com/bjarneo/cliamp/internal/appdir"
"github.com/bjarneo/cliamp/internal/fileutil"
"github.com/bjarneo/cliamp/internal/plugintrust"
"github.com/bjarneo/cliamp/luaplugin"
)
var httpClient = &http.Client{Timeout: 30 * time.Second}
const maxPluginSize = 1 << 20 // 1 MB
const metadataTimeout = 250 * time.Millisecond
var (
input io.Reader = os.Stdin
output io.Writer = os.Stdout
)
// pluginInfo holds metadata extracted from a plugin's register() call.
type pluginInfo struct {
file string
name string
version string
description string
typ string
permissions []string
path string
trust string
err error
}
// List prints all installed plugins with their metadata.
func List() error {
dir, err := appdir.PluginDir()
if err != nil {
return err
}
plugins, err := scanPlugins(dir)
if err != nil {
fmt.Println("No plugins installed.")
return nil
}
if len(plugins) == 0 {
fmt.Println("No plugins installed.")
return nil
}
manifest, trustErr := plugintrust.Load(dir)
if trustErr != nil {
return trustErr
}
for i := range plugins {
switch err := plugintrust.Verify(manifest, strings.TrimSuffix(plugins[i].file, "/"), plugins[i].path); {
case err == nil:
plugins[i].trust = "trusted"
case err == plugintrust.ErrHashMismatch:
plugins[i].trust = "changed"
default:
plugins[i].trust = "untrusted"
}
}
// Calculate column widths.
nameW, typeW, verW := 4, 4, 7 // "NAME", "TYPE", "VERSION"
for _, p := range plugins {
if len(p.name) > nameW {
nameW = len(p.name)
}
if len(p.typ) > typeW {
typeW = len(p.typ)
}
if len(p.version) > verW {
verW = len(p.version)
}
}
fmt.Fprintf(output, "%-*s %-*s %-*s %-9s %s\n", nameW, "NAME", typeW, "TYPE", verW, "VERSION", "TRUST", "DESCRIPTION")
for _, p := range plugins {
fmt.Fprintf(output, "%-*s %-*s %-*s %-9s %s\n", nameW, p.name, typeW, p.typ, verW, p.version, p.trust, p.description)
}
return nil
}
// Install downloads a plugin from the given source and saves it to the plugins directory.
func Install(source string, assumeYes ...bool) error {
urls, name, err := resolveSource(source)
if err != nil {
return err
}
if err := validateName(name); err != nil {
return err
}
dir, err := appdir.PluginDir()
if err != nil {
return err
}
if err := os.MkdirAll(dir, 0o700); err != nil {
return fmt.Errorf("creating plugins directory: %w", err)
}
if err := os.Chmod(dir, 0o700); err != nil {
return fmt.Errorf("securing plugins directory: %w", err)
}
// Check if already installed (file or directory).
dest := filepath.Join(dir, name+".lua")
if _, err := os.Stat(dest); err == nil {
return fmt.Errorf("plugin %q already exists at %s (remove it first with: cliamp plugins remove %s)", name, dest, name)
}
if info, err := os.Stat(filepath.Join(dir, name)); err == nil && info.IsDir() {
return fmt.Errorf("plugin %q already exists as directory (remove it first with: cliamp plugins remove %s)", name, name)
}
// Try each candidate URL.
var body []byte
for _, u := range urls {
fmt.Printf("Trying %s...\n", u)
b, err := download(u)
if err == nil {
body = b
break
}
}
if body == nil {
return fmt.Errorf("could not download plugin from any of: %s", strings.Join(urls, ", "))
}
info := extractMetadataSource(string(body))
if info.err != nil {
return fmt.Errorf("inspect plugin metadata: %w", info.err)
}
h := sha256.Sum256(body)
hash := hex.EncodeToString(h[:])
fmt.Fprintf(output, "Source: %s\nSHA-256: %s\nDeclared permissions: %s\nImplicit access: unrestricted reads; allowlisted writes; public HTTP\n",
source, hash, displayPermissions(info.permissions))
yes := len(assumeYes) > 0 && assumeYes[0]
if !yes {
fmt.Fprint(output, "Trust and install this plugin? [y/N] ")
answer, err := bufio.NewReader(input).ReadString('\n')
if err != nil && len(answer) == 0 {
return errors.New("approval required; rerun with --yes for non-interactive installation")
}
answer = strings.ToLower(strings.TrimSpace(answer))
if answer != "y" && answer != "yes" {
return errors.New("plugin installation not approved")
}
}
if err := fileutil.WriteFileAtomic(dest, body, 0o600); err != nil {
return fmt.Errorf("writing plugin: %w", err)
}
if _, err := plugintrust.Approve(dir, name, dest); err != nil {
_ = os.Remove(dest)
return fmt.Errorf("recording plugin trust: %w", err)
}
fmt.Printf("Installed %s → %s\n", name, dest)
return nil
}
// Trust approves the current contents of an installed plugin.
func Trust(name string, assumeYes bool) error {
if err := validateName(name); err != nil {
return err
}
dir, err := appdir.PluginDir()
if err != nil {
return err
}
path := filepath.Join(dir, name+".lua")
if st, statErr := os.Stat(path); statErr != nil {
path = filepath.Join(dir, name, "init.lua")
} else if st.IsDir() {
return fmt.Errorf("plugin %q not found", name)
}
info := extractMetadata(path)
if info.err != nil {
return fmt.Errorf("inspect plugin metadata: %w", info.err)
}
hash, err := plugintrust.HashFile(path)
if err != nil {
return err
}
fmt.Fprintf(output, "Plugin: %s\nSHA-256: %s\nDeclared permissions: %s\nImplicit access: unrestricted reads; allowlisted writes; public HTTP\n",
name, hash, displayPermissions(info.permissions))
if !assumeYes {
fmt.Fprint(output, "Trust this plugin content? [y/N] ")
answer, readErr := bufio.NewReader(input).ReadString('\n')
if readErr != nil && len(answer) == 0 {
return errors.New("approval required; rerun with --yes")
}
answer = strings.ToLower(strings.TrimSpace(answer))
if answer != "y" && answer != "yes" {
return errors.New("plugin trust not approved")
}
}
_, err = plugintrust.Approve(dir, name, path)
return err
}
func validateName(name string) error {
if name == "" || name == "." || name == ".." || filepath.Base(name) != name || strings.ContainsAny(name, `/\\`) {
return fmt.Errorf("invalid plugin name %q", name)
}
return nil
}
func displayPermissions(perms []string) string {
if len(perms) == 0 {
return "none"
}
return strings.Join(perms, ", ")
}
// Remove deletes a plugin by name.
func Remove(name string) error {
if err := validateName(name); err != nil {
return err
}
dir, err := appdir.PluginDir()
if err != nil {
return err
}
// Try single file first, then directory.
filePath := filepath.Join(dir, name+".lua")
if _, err := os.Stat(filePath); err == nil {
if err := os.Remove(filePath); err != nil {
return fmt.Errorf("removing plugin: %w", err)
}
fmt.Printf("Removed %s\n", filePath)
return nil
}
dirPath := filepath.Join(dir, name)
if info, err := os.Stat(dirPath); err == nil && info.IsDir() {
if err := os.RemoveAll(dirPath); err != nil {
return fmt.Errorf("removing plugin directory: %w", err)
}
fmt.Printf("Removed %s\n", dirPath)
return nil
}
return fmt.Errorf("plugin %q not found", name)
}
func download(url string) ([]byte, error) {
resp, err := httpClient.Get(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("HTTP %s", resp.Status)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, maxPluginSize+1))
if err != nil {
return nil, err
}
if len(body) > maxPluginSize {
return nil, fmt.Errorf("plugin too large (max %d bytes)", maxPluginSize)
}
if len(body) == 0 {
return nil, fmt.Errorf("empty response body")
}
return body, nil
}
// scanPlugins reads the plugin directory and extracts metadata from each plugin
// using a lightweight Lua VM.
func scanPlugins(dir string) ([]pluginInfo, error) {
entries, err := os.ReadDir(dir)
if err != nil {
return nil, err
}
var plugins []pluginInfo
for _, e := range entries {
var path, file string
if e.IsDir() {
init := filepath.Join(dir, e.Name(), "init.lua")
if _, err := os.Stat(init); err != nil {
continue
}
path = init
file = e.Name() + "/"
} else if strings.HasSuffix(e.Name(), ".lua") {
path = filepath.Join(dir, e.Name())
file = e.Name()
} else {
continue
}
info := extractMetadata(path)
info.file = file
info.path = path
if info.name == "" {
info.name = strings.TrimSuffix(e.Name(), ".lua")
}
plugins = append(plugins, info)
}
return plugins, nil
}
// extractMetadata runs a Lua file in a minimal VM to capture the plugin.register() call.
func extractMetadata(path string) pluginInfo {
data, err := os.ReadFile(path)
if err != nil {
return pluginInfo{err: err}
}
return extractMetadataSource(string(data))
}
func extractMetadataSource(source string) pluginInfo {
L := lua.NewState(lua.Options{SkipOpenLibs: false})
defer L.Close()
// Apply the same sandbox as the runtime: extractMetadata runs DoFile on
// the whole plugin file (not just register()), so top-level code must not
// have access to os.execute/io/dofile when merely listing plugins.
luaplugin.Sandbox(L)
var info pluginInfo
ctx, cancel := context.WithTimeout(context.Background(), metadataTimeout)
defer cancel()
L.SetContext(ctx)
defer L.RemoveContext()
// Stub out plugin.register() to capture metadata without side effects.
pluginTbl := L.NewTable()
L.SetField(pluginTbl, "register", L.NewFunction(func(L *lua.LState) int {
opts := L.CheckTable(1)
if v := opts.RawGetString("name"); v != lua.LNil {
info.name = v.String()
}
if v := opts.RawGetString("version"); v != lua.LNil {
info.version = v.String()
}
if v := opts.RawGetString("description"); v != lua.LNil {
info.description = v.String()
}
if v := opts.RawGetString("type"); v != lua.LNil {
info.typ = v.String()
}
if v := opts.RawGetString("permissions"); v != lua.LNil {
tbl, ok := v.(*lua.LTable)
if !ok {
info.err = errors.New("permissions must be an array")
} else {
known := map[string]bool{"control": true, "exec": true, "keymap": true}
tbl.ForEach(func(_, value lua.LValue) {
permission := value.String()
if !known[permission] && info.err == nil {
info.err = fmt.Errorf("unknown permission %q", permission)
}
info.permissions = append(info.permissions, permission)
})
}
}
// Return a dummy object with stub on/config methods.
obj := L.NewTable()
noop := L.NewFunction(func(L *lua.LState) int {
L.Push(lua.LNil)
return 1
})
L.SetField(obj, "on", noop)
L.SetField(obj, "config", noop)
L.Push(obj)
return 1
}))
L.SetGlobal("plugin", pluginTbl)
// No cliamp API is installed: metadata inspection happens before trust.
if err := L.DoString(source); err != nil && info.name == "" {
info.err = err
}
return info
}