402 lines
11 KiB
Go
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
|
|
}
|