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

381 lines
8.7 KiB
Go

package luaplugin
import (
"bufio"
"context"
"errors"
"io"
"os"
"os/exec"
"sync"
"sync/atomic"
"time"
lua "github.com/yuin/gopher-lua"
)
// Default binaries plugins may invoke via cliamp.exec.run(). Users can widen
// this via [plugins] allowed_binaries = "yt-dlp,ffmpeg,ffprobe" in config.toml.
var defaultAllowedBinaries = []string{"yt-dlp", "ffmpeg"}
// Per-process output cap (stdout+stderr). Once exceeded, further output is
// dropped and the process is still allowed to run to completion. Prevents a
// chatty subprocess from OOMing the player.
const execMaxOutputBytes = 4 << 20 // 4 MiB
// Per-plugin concurrency cap. Runaway plugins can't fork-bomb past this.
const execMaxPerPlugin = 4
// Hard timeout cap. Plugins may pass a smaller value; larger values clamp.
const execMaxTimeout = 30 * time.Minute
// execEntry tracks a single running subprocess.
type execEntry struct {
id int64
plugin *Plugin
cmd *exec.Cmd
cancel context.CancelFunc
done chan struct{}
}
// execManager owns all running plugin subprocesses.
type execManager struct {
mu sync.Mutex
entries map[int64]*execEntry
nextID atomic.Int64
perPlug map[*Plugin]int
allowed map[string]struct{}
allowMu sync.RWMutex
}
func newExecManager(allowed []string) *execManager {
em := &execManager{
entries: make(map[int64]*execEntry),
perPlug: make(map[*Plugin]int),
allowed: make(map[string]struct{}),
}
em.setAllowed(allowed)
return em
}
func (em *execManager) setAllowed(names []string) {
em.allowMu.Lock()
defer em.allowMu.Unlock()
em.allowed = make(map[string]struct{}, len(names))
for _, n := range names {
em.allowed[n] = struct{}{}
}
}
func (em *execManager) isAllowed(name string) bool {
em.allowMu.RLock()
defer em.allowMu.RUnlock()
_, ok := em.allowed[name]
return ok
}
func (em *execManager) add(e *execEntry) {
em.mu.Lock()
em.entries[e.id] = e
em.perPlug[e.plugin]++
em.mu.Unlock()
}
func (em *execManager) remove(e *execEntry) {
em.mu.Lock()
if _, ok := em.entries[e.id]; ok {
delete(em.entries, e.id)
em.perPlug[e.plugin]--
if em.perPlug[e.plugin] <= 0 {
delete(em.perPlug, e.plugin)
}
}
em.mu.Unlock()
}
// canStart returns true if the plugin is under its concurrency cap.
func (em *execManager) canStart(p *Plugin) bool {
em.mu.Lock()
defer em.mu.Unlock()
return em.perPlug[p] < execMaxPerPlugin
}
// stopPlugin cancels every process owned by the given plugin and blocks
// until each one has exited. Called from Manager.cleanupPlugin.
func (em *execManager) stopPlugin(p *Plugin) {
em.mu.Lock()
var victims []*execEntry
for _, e := range em.entries {
if e.plugin == p {
victims = append(victims, e)
}
}
em.mu.Unlock()
for _, e := range victims {
e.cancel()
}
for _, e := range victims {
<-e.done
}
}
// stopAll cancels every process. Called from Manager.Close.
func (em *execManager) stopAll() {
em.mu.Lock()
var all []*execEntry
for _, e := range em.entries {
all = append(all, e)
}
em.mu.Unlock()
for _, e := range all {
e.cancel()
}
for _, e := range all {
<-e.done
}
}
// registerExecAPI adds cliamp.exec.run(binary, args, opts?) -> handle, err.
// The exec API is only functional for plugins declaring permissions = {"exec"}.
// Without the permission, cliamp.exec is a no-op table that logs once.
func registerExecAPI(L *lua.LState, cliamp *lua.LTable, em *execManager, p *Plugin, logger *pluginLogger) {
tbl := L.NewTable()
warned := false
guard := func() bool {
if p.perms[PermExec] {
return true
}
if !warned {
logger.log(p.Name, "warn", "cliamp.exec requires permissions = {\"exec\"} — further warnings suppressed")
warned = true
}
return false
}
L.SetField(tbl, "run", L.NewFunction(func(L *lua.LState) int {
if !guard() {
L.Push(lua.LNil)
L.Push(lua.LString("exec permission required"))
return 2
}
binary := L.CheckString(1)
argsTbl := L.CheckTable(2)
optsTbl := L.OptTable(3, nil)
if !em.isAllowed(binary) {
L.Push(lua.LNil)
L.Push(lua.LString("binary not in allowlist: " + binary))
return 2
}
path, err := exec.LookPath(binary)
if err != nil {
L.Push(lua.LNil)
L.Push(lua.LString("binary not found on PATH: " + binary))
return 2
}
// Flatten argv. Every entry must be a string; reject non-strings rather
// than coercing, so plugins can't sneak nested tables into argv.
var argv []string
var argErr error
argsTbl.ForEach(func(_, v lua.LValue) {
if argErr != nil {
return
}
if v.Type() != lua.LTString {
argErr = errors.New("args must all be strings")
return
}
argv = append(argv, v.String())
})
if argErr != nil {
L.Push(lua.LNil)
L.Push(lua.LString(argErr.Error()))
return 2
}
var onStdout, onStderr, onExit *lua.LFunction
cwd := ""
timeout := execMaxTimeout
if optsTbl != nil {
if fn, ok := optsTbl.RawGetString("on_stdout").(*lua.LFunction); ok {
onStdout = fn
}
if fn, ok := optsTbl.RawGetString("on_stderr").(*lua.LFunction); ok {
onStderr = fn
}
if fn, ok := optsTbl.RawGetString("on_exit").(*lua.LFunction); ok {
onExit = fn
}
if s, ok := optsTbl.RawGetString("cwd").(lua.LString); ok {
cwd = string(s)
}
if n, ok := optsTbl.RawGetString("timeout").(lua.LNumber); ok && float64(n) > 0 {
t := time.Duration(float64(n) * float64(time.Second))
if t < timeout {
timeout = t
}
}
}
if cwd != "" && !isWriteAllowed(cwd) {
L.Push(lua.LNil)
L.Push(lua.LString("cwd not in write allowlist"))
return 2
}
if !em.canStart(p) {
L.Push(lua.LNil)
L.Push(lua.LString("per-plugin exec concurrency cap reached"))
return 2
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
cmd := exec.CommandContext(ctx, path, argv...)
if cwd != "" {
cmd.Dir = cwd
}
// Empty env by default — plugins should not inherit secrets like
// AWS_*, SSH_*, etc. yt-dlp and ffmpeg both run fine with a minimal env.
cmd.Env = minimalExecEnv()
stdout, err := cmd.StdoutPipe()
if err != nil {
cancel()
L.Push(lua.LNil)
L.Push(lua.LString(err.Error()))
return 2
}
stderr, err := cmd.StderrPipe()
if err != nil {
cancel()
L.Push(lua.LNil)
L.Push(lua.LString(err.Error()))
return 2
}
if err := cmd.Start(); err != nil {
cancel()
L.Push(lua.LNil)
L.Push(lua.LString(err.Error()))
return 2
}
id := em.nextID.Add(1)
entry := &execEntry{
id: id,
plugin: p,
cmd: cmd,
cancel: cancel,
done: make(chan struct{}),
}
em.add(entry)
// Shared output budget across stdout+stderr.
var outUsed atomic.Int64
pipeStream := func(r io.Reader, fn *lua.LFunction) {
scanner := bufio.NewScanner(r)
// Allow longer lines than default 64KiB for noisy tools like ffmpeg.
scanner.Buffer(make([]byte, 64*1024), 1<<20)
for scanner.Scan() {
line := scanner.Text()
if outUsed.Add(int64(len(line)+1)) > execMaxOutputBytes {
// Budget exhausted — drain silently.
for scanner.Scan() {
}
return
}
if fn == nil {
continue
}
p.mu.Lock()
_ = p.L.CallByParam(lua.P{
Fn: fn,
NRet: 0,
Protect: true,
}, lua.LString(line))
p.mu.Unlock()
}
}
var wg sync.WaitGroup
wg.Add(2)
go func() { defer wg.Done(); pipeStream(stdout, onStdout) }()
go func() { defer wg.Done(); pipeStream(stderr, onStderr) }()
go func() {
wg.Wait()
waitErr := cmd.Wait()
ctxErr := ctx.Err()
cancel()
em.remove(entry)
code := 0
if waitErr != nil {
if ctxErr != nil {
code = -1 // cancelled or timed out
} else {
var exitErr *exec.ExitError
if errors.As(waitErr, &exitErr) {
code = exitErr.ExitCode()
} else {
code = -2 // other error
}
}
}
if onExit != nil {
p.mu.Lock()
_ = p.L.CallByParam(lua.P{
Fn: onExit,
NRet: 0,
Protect: true,
}, lua.LNumber(code))
p.mu.Unlock()
}
close(entry.done)
}()
// Build a Lua handle with :cancel() and :alive().
handle := L.NewTable()
L.SetField(handle, "cancel", L.NewFunction(func(L *lua.LState) int {
entry.cancel()
return 0
}))
L.SetField(handle, "alive", L.NewFunction(func(L *lua.LState) int {
select {
case <-entry.done:
L.Push(lua.LFalse)
default:
L.Push(lua.LTrue)
}
return 1
}))
L.SetField(handle, "id", lua.LNumber(id))
L.Push(handle)
return 1
}))
L.SetField(cliamp, "exec", tbl)
}
// homeEnv returns the user's home directory for subprocess HOME, preferring
// $HOME, then os.UserHomeDir(), falling back to os.TempDir() when unset.
// yt-dlp and ffmpeg both read HOME (~/.cache, ~/.config).
func homeEnv() string {
if home, ok := os.LookupEnv("HOME"); ok && home != "" {
return home
}
if h, err := os.UserHomeDir(); err == nil {
return h
}
return os.TempDir()
}