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