248 lines
6.4 KiB
Go
248 lines
6.4 KiB
Go
package luaplugin
|
|
|
|
import (
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"sync"
|
|
|
|
lua "github.com/yuin/gopher-lua"
|
|
|
|
"github.com/bjarneo/cliamp/internal/appdir"
|
|
)
|
|
|
|
var (
|
|
allowDirsOnce sync.Once
|
|
allowDirs []string
|
|
)
|
|
|
|
// writeAllowDirs returns the directories where plugins can write files, with
|
|
// symlinks resolved so the prefix check in isWriteAllowed cannot be bypassed
|
|
// by a symlinked allow dir (e.g. /tmp -> /private/tmp on macOS). Entries are
|
|
// normalized via normalizeWritePath so isWriteAllowed can compare directly.
|
|
// The result is cached since these paths never change at runtime.
|
|
func writeAllowDirs() []string {
|
|
allowDirsOnce.Do(func() {
|
|
raw := []string{"/tmp", os.TempDir()}
|
|
if configDir, err := appdir.Dir(); err == nil {
|
|
raw = append(raw, configDir)
|
|
}
|
|
if home, err := os.UserHomeDir(); err == nil {
|
|
raw = append(raw, filepath.Join(home, ".local", "share", "cliamp"))
|
|
raw = append(raw, filepath.Join(home, "Music", "cliamp"))
|
|
}
|
|
for _, d := range raw {
|
|
abs, err := filepath.Abs(d)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if resolved, err := filepath.EvalSymlinks(abs); err == nil {
|
|
abs = resolved
|
|
}
|
|
allowDirs = append(allowDirs, normalizeWritePath(abs))
|
|
}
|
|
})
|
|
return allowDirs
|
|
}
|
|
|
|
// canonicalExistingPath resolves symlinks on the deepest existing ancestor of
|
|
// path, re-appending any non-existent tail (e.g. a file about to be created).
|
|
// This prevents a symlink planted inside an allowed dir from redirecting a
|
|
// write to a target outside it; a purely lexical check cannot catch that.
|
|
func canonicalExistingPath(path string) (string, bool) {
|
|
abs, err := filepath.Abs(path)
|
|
if err != nil {
|
|
return "", false
|
|
}
|
|
suffix := ""
|
|
cur := abs
|
|
for {
|
|
if resolved, err := filepath.EvalSymlinks(cur); err == nil {
|
|
if suffix != "" {
|
|
resolved = filepath.Join(resolved, suffix)
|
|
}
|
|
return resolved, true
|
|
}
|
|
parent := filepath.Dir(cur)
|
|
if parent == cur {
|
|
return abs, true // nothing along the path exists yet
|
|
}
|
|
suffix = filepath.Join(filepath.Base(cur), suffix)
|
|
cur = parent
|
|
}
|
|
}
|
|
|
|
// isWriteAllowed checks if a path is within one of the allowed write
|
|
// directories, resolving symlinks on both sides first.
|
|
func isWriteAllowed(path string) bool {
|
|
abs, ok := canonicalExistingPath(path)
|
|
if !ok {
|
|
return false
|
|
}
|
|
abs = normalizeWritePath(abs)
|
|
for _, dir := range writeAllowDirs() {
|
|
if abs == dir || strings.HasPrefix(abs, dir+string(os.PathSeparator)) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// normalizeWritePath canonicalizes an absolute path for prefix comparison:
|
|
// cleaned and, on Windows, case-folded (Windows paths are case-insensitive).
|
|
func normalizeWritePath(path string) string {
|
|
path = filepath.Clean(path)
|
|
if runtime.GOOS == "windows" {
|
|
path = strings.ToLower(path)
|
|
}
|
|
return path
|
|
}
|
|
|
|
// registerFSAPI adds cliamp.fs.{write,append,read,remove,exists} to the cliamp table.
|
|
func registerFSAPI(L *lua.LState, cliamp *lua.LTable) {
|
|
tbl := L.NewTable()
|
|
|
|
// cliamp.fs.write(path, content)
|
|
L.SetField(tbl, "write", L.NewFunction(func(L *lua.LState) int {
|
|
path := L.CheckString(1)
|
|
content := L.CheckString(2)
|
|
if !isWriteAllowed(path) {
|
|
L.ArgError(1, "write not allowed to this path")
|
|
return 0
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
L.Push(lua.LNil)
|
|
L.Push(lua.LString(err.Error()))
|
|
return 2
|
|
}
|
|
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
|
L.Push(lua.LNil)
|
|
L.Push(lua.LString(err.Error()))
|
|
return 2
|
|
}
|
|
L.Push(lua.LTrue)
|
|
return 1
|
|
}))
|
|
|
|
// cliamp.fs.append(path, content)
|
|
L.SetField(tbl, "append", L.NewFunction(func(L *lua.LState) int {
|
|
path := L.CheckString(1)
|
|
content := L.CheckString(2)
|
|
if !isWriteAllowed(path) {
|
|
L.ArgError(1, "write not allowed to this path")
|
|
return 0
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
L.Push(lua.LNil)
|
|
L.Push(lua.LString(err.Error()))
|
|
return 2
|
|
}
|
|
f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
|
|
if err != nil {
|
|
L.Push(lua.LNil)
|
|
L.Push(lua.LString(err.Error()))
|
|
return 2
|
|
}
|
|
_, err = f.WriteString(content)
|
|
f.Close()
|
|
if err != nil {
|
|
L.Push(lua.LNil)
|
|
L.Push(lua.LString(err.Error()))
|
|
return 2
|
|
}
|
|
L.Push(lua.LTrue)
|
|
return 1
|
|
}))
|
|
|
|
// cliamp.fs.read(path) -> string (max 1MB)
|
|
L.SetField(tbl, "read", L.NewFunction(func(L *lua.LState) int {
|
|
path := L.CheckString(1)
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
L.Push(lua.LNil)
|
|
L.Push(lua.LString(err.Error()))
|
|
return 2
|
|
}
|
|
defer f.Close()
|
|
const maxSize = 1 << 20 // 1MB
|
|
// Read one byte past the cap so an oversized file is detected without
|
|
// pulling the whole thing into memory, then reject it explicitly
|
|
// rather than returning a silently truncated value.
|
|
data, err := io.ReadAll(io.LimitReader(f, maxSize+1))
|
|
if err != nil {
|
|
L.Push(lua.LNil)
|
|
L.Push(lua.LString(err.Error()))
|
|
return 2
|
|
}
|
|
if len(data) > maxSize {
|
|
L.Push(lua.LNil)
|
|
L.Push(lua.LString("file exceeds 1MB read limit"))
|
|
return 2
|
|
}
|
|
L.Push(lua.LString(string(data)))
|
|
return 1
|
|
}))
|
|
|
|
// cliamp.fs.remove(path)
|
|
L.SetField(tbl, "remove", L.NewFunction(func(L *lua.LState) int {
|
|
path := L.CheckString(1)
|
|
if !isWriteAllowed(path) {
|
|
L.ArgError(1, "remove not allowed for this path")
|
|
return 0
|
|
}
|
|
if err := os.Remove(path); err != nil {
|
|
L.Push(lua.LNil)
|
|
L.Push(lua.LString(err.Error()))
|
|
return 2
|
|
}
|
|
L.Push(lua.LTrue)
|
|
return 1
|
|
}))
|
|
|
|
// cliamp.fs.exists(path) -> boolean
|
|
L.SetField(tbl, "exists", L.NewFunction(func(L *lua.LState) int {
|
|
path := L.CheckString(1)
|
|
_, err := os.Stat(path)
|
|
L.Push(lua.LBool(err == nil))
|
|
return 1
|
|
}))
|
|
|
|
// cliamp.fs.mkdir(path) — recursive; path must be in write allowlist.
|
|
L.SetField(tbl, "mkdir", L.NewFunction(func(L *lua.LState) int {
|
|
path := L.CheckString(1)
|
|
if !isWriteAllowed(path) {
|
|
L.ArgError(1, "mkdir not allowed for this path")
|
|
return 0
|
|
}
|
|
if err := os.MkdirAll(path, 0o755); err != nil {
|
|
L.Push(lua.LNil)
|
|
L.Push(lua.LString(err.Error()))
|
|
return 2
|
|
}
|
|
L.Push(lua.LTrue)
|
|
return 1
|
|
}))
|
|
|
|
// cliamp.fs.listdir(path) -> {names}, err
|
|
// Reading is unrestricted (matches cliamp.fs.read); returns entry names only.
|
|
L.SetField(tbl, "listdir", L.NewFunction(func(L *lua.LState) int {
|
|
path := L.CheckString(1)
|
|
entries, err := os.ReadDir(path)
|
|
if err != nil {
|
|
L.Push(lua.LNil)
|
|
L.Push(lua.LString(err.Error()))
|
|
return 2
|
|
}
|
|
result := L.NewTable()
|
|
for i, e := range entries {
|
|
result.RawSetInt(i+1, lua.LString(e.Name()))
|
|
}
|
|
L.Push(result)
|
|
return 1
|
|
}))
|
|
|
|
L.SetField(cliamp, "fs", tbl)
|
|
}
|