chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,380 @@
|
||||
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()
|
||||
}
|
||||
Reference in New Issue
Block a user