Files
seanime-docker/seanime-2.9.10/internal/plugin/system.go
2025-09-20 14:08:38 +01:00

1018 lines
29 KiB
Go

package plugin
import (
"bufio"
"bytes"
"context"
"errors"
"fmt"
"io"
"io/fs"
"mime"
"os"
"os/exec"
"path/filepath"
"regexp"
"runtime"
"seanime/internal/extension"
util "seanime/internal/util"
goja_util "seanime/internal/util/goja"
"strings"
"sync"
"github.com/bmatcuk/doublestar/v4"
"github.com/dop251/goja"
"github.com/rs/zerolog"
)
var (
ErrPathNotAuthorized = errors.New("path not authorized")
)
const (
AllowPathRead = 0
AllowPathWrite = 1
)
type AsyncCmd struct {
cmd *exec.Cmd
appContext *AppContextImpl
scheduler *goja_util.Scheduler
vm *goja.Runtime
}
type CmdHelper struct {
cmd *exec.Cmd
stdout io.ReadCloser
stderr io.ReadCloser
appContext *AppContextImpl
scheduler *goja_util.Scheduler
vm *goja.Runtime
}
// BindSystem binds the system module to the Goja runtime.
// Permissions needed: system + allowlist
func (a *AppContextImpl) BindSystem(vm *goja.Runtime, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler) {
//////////////////////////////////////
// OS
//////////////////////////////////////
osObj := vm.NewObject()
// _ = osObj.Set("args", os.Args) // NOT INCLUDED
// _ = osObj.Set("exit", os.Exit) // NOT INCLUDED
// _ = osObj.Set("getenv", os.Getenv) // NOT INCLUDED
// _ = osObj.Set("dirFS", os.DirFS) // NOT INCLUDED
// _ = osObj.Set("getwd", os.Getwd) // NOT INCLUDED
// _ = osObj.Set("chown", os.Chown) // NOT INCLUDED
// e.g. $os.platform // "windows"
_ = osObj.Set("platform", runtime.GOOS)
// e.g. $os.arch // "amd64"
_ = osObj.Set("arch", runtime.GOARCH)
_ = osObj.Set("cmd", func(name string, arg ...string) (*exec.Cmd, error) {
if !a.isAllowedCommand(ext, name, arg...) {
return nil, fmt.Errorf("command (%s) not authorized", fmt.Sprintf("%s %s", name, strings.Join(arg, " ")))
}
return util.NewCmdCtx(context.Background(), name, arg...), nil
})
_ = osObj.Set("Interrupt", os.Interrupt)
_ = osObj.Set("Kill", os.Kill)
_ = osObj.Set("readFile", func(path string) ([]byte, error) {
if !a.isAllowedPath(ext, path, AllowPathRead) {
return nil, fmt.Errorf("$os.readFile: path (%s) not authorized for read", path)
}
return os.ReadFile(path)
})
_ = osObj.Set("writeFile", func(path string, data []byte, perm fs.FileMode) error {
if !a.isAllowedPath(ext, path, AllowPathWrite) {
return fmt.Errorf("$os.writeFile: path (%s) not authorized for write", path)
}
return os.WriteFile(path, data, perm)
})
_ = osObj.Set("readDir", func(path string) ([]fs.DirEntry, error) {
if !a.isAllowedPath(ext, path, AllowPathRead) {
return nil, fmt.Errorf("$os.readDir: path (%s) not authorized for read", path)
}
return os.ReadDir(path)
})
_ = osObj.Set("tempDir", func() (string, error) {
if !a.isAllowedPath(ext, os.TempDir(), AllowPathRead) {
return "", fmt.Errorf("$os.tempDir: path (%s) not authorized for read", os.TempDir())
}
return os.TempDir(), nil
})
_ = osObj.Set("configDir", func() (string, error) {
configDir, err := os.UserConfigDir()
if err != nil {
return "", err
}
if !a.isAllowedPath(ext, configDir, AllowPathRead) {
return "", fmt.Errorf("$os.configDir: path (%s) not authorized for read", configDir)
}
return configDir, nil
})
_ = osObj.Set("homeDir", func() (string, error) {
homeDir, err := os.UserHomeDir()
if err != nil {
return "", err
}
if !a.isAllowedPath(ext, homeDir, AllowPathRead) {
return "", fmt.Errorf("$os.homeDir: path (%s) not authorized for read", homeDir)
}
return homeDir, nil
})
_ = osObj.Set("cacheDir", func() (string, error) {
cacheDir, err := os.UserCacheDir()
if err != nil {
return "", err
}
if !a.isAllowedPath(ext, cacheDir, AllowPathRead) {
return "", fmt.Errorf("$os.cacheDir: path (%s) not authorized for read", cacheDir)
}
return cacheDir, nil
})
_ = osObj.Set("truncate", func(path string, size int64) error {
if !a.isAllowedPath(ext, path, AllowPathWrite) {
return fmt.Errorf("$os.truncate: path (%s) not authorized for write", path)
}
return os.Truncate(path, size)
})
_ = osObj.Set("mkdir", func(path string, perm fs.FileMode) error {
if !a.isAllowedPath(ext, path, AllowPathWrite) {
return fmt.Errorf("$os.mkdir: path (%s) not authorized for write", path)
}
return os.Mkdir(path, perm)
})
_ = osObj.Set("mkdirAll", func(path string, perm fs.FileMode) error {
if !a.isAllowedPath(ext, path, AllowPathWrite) {
return fmt.Errorf("$os.mkdirAll: path (%s) not authorized for write", path)
}
return os.MkdirAll(path, perm)
})
_ = osObj.Set("rename", func(oldpath, newpath string) error {
if !a.isAllowedPath(ext, oldpath, AllowPathWrite) || !a.isAllowedPath(ext, newpath, AllowPathWrite) {
return fmt.Errorf("$os.rename: path (%s) not authorized for write", oldpath)
}
return os.Rename(oldpath, newpath)
})
_ = osObj.Set("remove", func(path string) error {
if !a.isAllowedPath(ext, path, AllowPathWrite) {
return fmt.Errorf("$os.remove: path (%s) not authorized for write", path)
}
return os.Remove(path)
})
_ = osObj.Set("removeAll", func(path string) error {
if !a.isAllowedPath(ext, path, AllowPathWrite) {
return fmt.Errorf("$os.removeAll: path (%s) not authorized for write", path)
}
return os.RemoveAll(path)
})
_ = osObj.Set("stat", func(path string) (fs.FileInfo, error) {
if !a.isAllowedPath(ext, path, AllowPathRead) {
return nil, fmt.Errorf("$os.stat: path (%s) not authorized for read", path)
}
return os.Stat(path)
})
_ = osObj.Set("O_RDONLY", os.O_RDONLY)
_ = osObj.Set("O_WRONLY", os.O_WRONLY)
_ = osObj.Set("O_RDWR", os.O_RDWR)
_ = osObj.Set("O_APPEND", os.O_APPEND)
_ = osObj.Set("O_CREATE", os.O_CREATE)
_ = osObj.Set("O_EXCL", os.O_EXCL)
_ = osObj.Set("O_SYNC", os.O_SYNC)
_ = osObj.Set("O_TRUNC", os.O_TRUNC)
// Example:
// const file = $os.openFile("path/to/file.txt", $os.O_RDWR|$os.O_CREATE, 0644)
// file.writeString("Hello, world!")
// file.close()
_ = osObj.Set("openFile", func(path string, flag int, perm fs.FileMode) (*os.File, error) {
if !a.isAllowedPath(ext, path, AllowPathWrite) {
return nil, fmt.Errorf("$os.openFile: path (%s) not authorized for write", path)
}
return os.OpenFile(path, flag, perm)
})
// Example:
// const file = $os.create("path/to/file.txt")
// file.writeString("Hello, world!")
// file.close()
_ = osObj.Set("create", func(path string) (*os.File, error) {
if !a.isAllowedPath(ext, path, AllowPathWrite) {
return nil, fmt.Errorf("$os.create: path (%s) not authorized for write", path)
}
return os.Create(path)
})
fileModeObj := vm.NewObject()
fileModeObj.Set("ModeDir", os.ModeDir)
fileModeObj.Set("ModeAppend", os.ModeAppend)
fileModeObj.Set("ModeExclusive", os.ModeExclusive)
fileModeObj.Set("ModeTemporary", os.ModeTemporary)
fileModeObj.Set("ModeSymlink", os.ModeSymlink)
fileModeObj.Set("ModeDevice", os.ModeDevice)
fileModeObj.Set("ModeNamedPipe", os.ModeNamedPipe)
fileModeObj.Set("ModeSocket", os.ModeSocket)
fileModeObj.Set("ModeSetuid", os.ModeSetuid)
fileModeObj.Set("ModeSetgid", os.ModeSetgid)
fileModeObj.Set("ModeCharDevice", os.ModeCharDevice)
fileModeObj.Set("ModeSticky", os.ModeSticky)
fileModeObj.Set("ModeIrregular", os.ModeIrregular)
fileModeObj.Set("ModeType", os.ModeType)
fileModeObj.Set("ModePerm", os.ModePerm)
_ = osObj.Set("FileMode", fileModeObj)
_ = vm.Set("$os", osObj)
//////////////////////////////////////
// IO
//////////////////////////////////////
ioObj := vm.NewObject()
ioObj.Set("copy", func(dst io.Writer, src io.Reader) (int64, error) {
return io.Copy(dst, src)
})
ioObj.Set("readAll", func(r io.Reader) ([]byte, error) {
return io.ReadAll(r)
})
ioObj.Set("writeString", func(w io.Writer, s string) (int, error) {
return io.WriteString(w, s)
})
ioObj.Set("readAtLeast", func(r io.Reader, buf []byte, min int) (int, error) {
return io.ReadAtLeast(r, buf, min)
})
ioObj.Set("readFull", func(r io.Reader, buf []byte) (int, error) {
return io.ReadFull(r, buf)
})
ioObj.Set("copyN", func(dst io.Writer, src io.Reader, n int64) (int64, error) {
return io.CopyN(dst, src, n)
})
ioObj.Set("copyBuffer", func(dst io.Writer, src io.Reader, buf []byte) (int64, error) {
return io.CopyBuffer(dst, src, buf)
})
ioObj.Set("limitReader", func(r io.Reader, n int64) io.Reader {
return io.LimitReader(r, n)
})
ioObj.Set("newSectionReader", func(r io.ReaderAt, off int64, n int64) io.Reader {
return io.NewSectionReader(r, off, n)
})
ioObj.Set("nopCloser", func(r io.Reader) io.ReadCloser {
return io.NopCloser(r)
})
_ = vm.Set("$io", ioObj)
//////////////////////////////////////
// bufio
//////////////////////////////////////
bufioObj := vm.NewObject()
bufioObj.Set("newReader", func(r io.Reader) *bufio.Reader {
return bufio.NewReader(r)
})
bufioObj.Set("newReaderSize", func(r io.Reader, size int) *bufio.Reader {
return bufio.NewReaderSize(r, size)
})
bufioObj.Set("newWriter", func(w io.Writer) *bufio.Writer {
return bufio.NewWriter(w)
})
bufioObj.Set("newWriterSize", func(w io.Writer, size int) *bufio.Writer {
return bufio.NewWriterSize(w, size)
})
bufioObj.Set("newScanner", func(r io.Reader) *bufio.Scanner {
return bufio.NewScanner(r)
})
bufioObj.Set("scanLines", func(data []byte, atEOF bool) (advance int, token []byte, err error) {
return bufio.ScanLines(data, atEOF)
})
bufioObj.Set("scanWords", func(data []byte, atEOF bool) (advance int, token []byte, err error) {
return bufio.ScanWords(data, atEOF)
})
bufioObj.Set("scanRunes", func(data []byte, atEOF bool) (advance int, token []byte, err error) {
return bufio.ScanRunes(data, atEOF)
})
bufioObj.Set("scanBytes", func(data []byte, atEOF bool) (advance int, token []byte, err error) {
return bufio.ScanBytes(data, atEOF)
})
_ = vm.Set("$bufio", bufioObj)
//////////////////////////////////////
// bytes
//////////////////////////////////////
bytesObj := vm.NewObject()
bytesObj.Set("newBuffer", func(buf []byte) *bytes.Buffer {
return bytes.NewBuffer(buf)
})
bytesObj.Set("newBufferString", func(s string) *bytes.Buffer {
return bytes.NewBufferString(s)
})
bytesObj.Set("newReader", func(b []byte) *bytes.Reader {
return bytes.NewReader(b)
})
_ = vm.Set("$bytes", bytesObj)
//////////////////////////////////////
// Filepath
//////////////////////////////////////
filepathObj := vm.NewObject()
filepathObj.Set("base", filepath.Base)
filepathObj.Set("clean", filepath.Clean)
filepathObj.Set("dir", filepath.Dir)
filepathObj.Set("ext", filepath.Ext)
filepathObj.Set("fromSlash", filepath.FromSlash)
filepathObj.Set("glob", func(basePath string, pattern string) ([]string, error) {
if !a.isAllowedPath(ext, basePath, AllowPathRead) {
return nil, fmt.Errorf("$filepath.glob: path (%s) not authorized for read", basePath)
}
return doublestar.Glob(os.DirFS(basePath), pattern)
})
filepathObj.Set("isAbs", filepath.IsAbs)
filepathObj.Set("join", filepath.Join)
filepathObj.Set("match", doublestar.Match)
filepathObj.Set("rel", filepath.Rel)
filepathObj.Set("split", filepath.Split)
filepathObj.Set("splitList", filepath.SplitList)
filepathObj.Set("toSlash", filepath.ToSlash)
filepathObj.Set("walk", func(root string, walkFn filepath.WalkFunc) error {
if !a.isAllowedPath(ext, root, AllowPathRead) {
return fmt.Errorf("$filepath.walk: path (%s) not authorized for read", root)
}
return filepath.Walk(root, walkFn)
})
filepathObj.Set("walkDir", func(root string, walkFn fs.WalkDirFunc) error {
if !a.isAllowedPath(ext, root, AllowPathRead) {
return fmt.Errorf("$filepath.walkDir: path (%s) not authorized for read", root)
}
return filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
return walkFn(path, d, err)
})
})
skipDir := filepath.SkipDir
filepathObj.Set("skipDir", skipDir)
_ = vm.Set("$filepath", filepathObj)
//////////////////////////////////////
// osextra
//////////////////////////////////////
osExtraObj := vm.NewObject()
osExtraObj.Set("unwrapAndMove", func(src string, dest string) error {
if !a.isAllowedPath(ext, src, AllowPathWrite) || !a.isAllowedPath(ext, dest, AllowPathWrite) {
return fmt.Errorf("$osExtra.unwrapAndMove: path (%s) not authorized for write", src)
}
return util.UnwrapAndMove(src, dest)
})
osExtraObj.Set("unzip", func(src string, dest string) error {
if !a.isAllowedPath(ext, src, AllowPathWrite) || !a.isAllowedPath(ext, dest, AllowPathWrite) {
return fmt.Errorf("$osExtra.unzip: path (%s) not authorized for write", src)
}
return util.UnzipFile(src, dest)
})
osExtraObj.Set("unrar", func(src string, dest string) error {
if !a.isAllowedPath(ext, src, AllowPathWrite) || !a.isAllowedPath(ext, dest, AllowPathWrite) {
return fmt.Errorf("$osExtra.unrar: path (%s) not authorized for write", src)
}
return util.UnrarFile(src, dest)
})
osExtraObj.Set("downloadDir", func() (string, error) {
donwloadDir, err := util.DownloadDir()
if err != nil {
return "", err
}
if !a.isAllowedPath(ext, donwloadDir, AllowPathRead) {
return "", fmt.Errorf("$osExtra.downloadDir: path not authorized for read")
}
return donwloadDir, nil
})
osExtraObj.Set("desktopDir", func() (string, error) {
desktopDir, err := util.DesktopDir()
if err != nil {
return "", err
}
if !a.isAllowedPath(ext, desktopDir, AllowPathRead) {
return "", fmt.Errorf("$osExtra.desktopDir: path not authorized for read")
}
return desktopDir, nil
})
osExtraObj.Set("documentsDir", func() (string, error) {
documentsDir, err := util.DocumentsDir()
if err != nil {
return "", err
}
if !a.isAllowedPath(ext, documentsDir, AllowPathRead) {
return "", fmt.Errorf("$osExtra.documentsDir: path not authorized for read")
}
return documentsDir, nil
})
osExtraObj.Set("libraryDirs", func() ([]string, error) {
libraryDirs := []string{}
if animeLibraryPaths, ok := a.animeLibraryPaths.Get(); ok && len(animeLibraryPaths) > 0 {
for _, path := range animeLibraryPaths {
if !a.isAllowedPath(ext, path, AllowPathRead) {
return nil, fmt.Errorf("$osExtra.libraryDirs: path not authorized for read")
}
libraryDirs = append(libraryDirs, path)
}
}
return libraryDirs, nil
})
_ = osExtraObj.Set("asyncCmd", func(name string, arg ...string) *AsyncCmd {
return &AsyncCmd{
cmd: util.NewCmdCtx(context.Background(), name, arg...),
appContext: a,
scheduler: scheduler,
vm: vm,
}
})
_ = vm.Set("$osExtra", osExtraObj)
//////////////////////////////////////
// mime
//////////////////////////////////////
mimeObj := vm.NewObject()
mimeObj.Set("parse", func(contentType string) (map[string]interface{}, error) {
res, params, err := mime.ParseMediaType(contentType)
if err != nil {
return nil, err
}
return map[string]interface{}{
"mediaType": res,
"parameters": params,
}, nil
})
mimeObj.Set("format", mime.FormatMediaType)
_ = vm.Set("$mime", mimeObj)
}
// resolveEnvironmentPaths resolves environment paths in the form of $NAME
// to the actual path.
//
// e.g. $SEANIME_LIBRARY_PATH -> /home/user/anime
func (a *AppContextImpl) resolveEnvironmentPaths(name string) []string {
switch name {
case "SEANIME_ANIME_LIBRARY":
if animeLibraryPaths, ok := a.animeLibraryPaths.Get(); ok && len(animeLibraryPaths) > 0 {
return animeLibraryPaths
}
return []string{}
case "HOME": // %USERPROFILE% on Windows
homeDir, err := os.UserHomeDir()
if err != nil {
return []string{}
}
return []string{homeDir}
case "CACHE": // LocalAppData on Windows
cacheDir, err := os.UserCacheDir()
if err != nil {
return []string{}
}
return []string{cacheDir}
case "TEMP": // %TMP%, %TEMP% or %USERPROFILE% on Windows
tempDir := os.TempDir()
if tempDir == "" {
return []string{}
}
return []string{tempDir}
case "CONFIG": // AppData on Windows
configDir, err := os.UserConfigDir()
if err != nil {
return []string{}
}
return []string{configDir}
case "DOWNLOAD":
downloadDir, err := util.DownloadDir()
if err != nil {
return []string{}
}
return []string{downloadDir}
case "DESKTOP":
desktopDir, err := util.DesktopDir()
if err != nil {
return []string{}
}
return []string{desktopDir}
case "DOCUMENT":
documentsDir, err := util.DocumentsDir()
if err != nil {
return []string{}
}
return []string{documentsDir}
}
return []string{}
}
func (a *AppContextImpl) isAllowedPath(ext *extension.Extension, path string, mode int) bool {
// If the extension doesn't have a plugin manifest or system allowlist, deny access
if ext == nil || ext.Plugin == nil {
return false
}
// Get the appropriate patterns based on the mode
var patterns []string
if mode == AllowPathRead {
patterns = ext.Plugin.Permissions.Allow.ReadPaths
} else if mode == AllowPathWrite {
patterns = ext.Plugin.Permissions.Allow.WritePaths
} else {
// Unknown mode
return false
}
// If no patterns are defined, deny access
if len(patterns) == 0 {
return false
}
// Normalize the path to use forward slashes and absolute path
normalizedPath := path
if !filepath.IsAbs(normalizedPath) {
absPath, err := filepath.Abs(normalizedPath)
if err != nil {
return false
}
normalizedPath = absPath
}
normalizedPath = filepath.ToSlash(normalizedPath)
// Check if the path is a directory
isDir := false
if stat, err := os.Stat(path); err == nil && stat.IsDir() {
isDir = true
// Ensure directory paths end with a slash for proper matching
if !strings.HasSuffix(normalizedPath, "/") {
normalizedPath += "/"
}
}
// Check if the path matches any of the allowed patterns
for _, pattern := range patterns {
// Resolve environment variables in the pattern, which may result in multiple patterns
resolvedPatterns := a.resolvePattern(pattern)
for _, resolvedPattern := range resolvedPatterns {
// Convert to absolute path if needed
if !filepath.IsAbs(resolvedPattern) && !strings.HasPrefix(resolvedPattern, "*") {
resolvedPattern = filepath.Join(filepath.Dir(normalizedPath), resolvedPattern)
}
// Direct match attempt
matched, err := doublestar.Match(resolvedPattern, normalizedPath)
if err == nil && matched {
return true
}
// For directories, we need special handling
if isDir {
// Case 1: Check if this directory is explicitly allowed by a pattern ending with "/"
if !strings.HasSuffix(resolvedPattern, "/") {
dirPattern := resolvedPattern
if !strings.HasSuffix(dirPattern, "/") {
dirPattern += "/"
}
matched, err = doublestar.Match(dirPattern, normalizedPath)
if err == nil && matched {
return true
}
}
// Case 2: Check if this directory is covered by a wildcard pattern
// Strip trailing wildcards to get the base directory pattern
basePattern := resolvedPattern
basePattern = strings.TrimSuffix(basePattern, "/**/*")
basePattern = strings.TrimSuffix(basePattern, "/**")
basePattern = strings.TrimSuffix(basePattern, "/*")
// Ensure the base pattern ends with a slash for directory comparison
if !strings.HasSuffix(basePattern, "/") {
basePattern += "/"
}
// If the path is exactly the base directory or a subdirectory of it
// AND the original pattern had a wildcard
if (normalizedPath == basePattern || strings.HasPrefix(normalizedPath, basePattern)) &&
(strings.HasSuffix(resolvedPattern, "/**") ||
strings.HasSuffix(resolvedPattern, "/**/*") ||
strings.HasSuffix(resolvedPattern, "/*")) {
return true
}
// Case 3: Check if the pattern is for a subdirectory of this directory
// This handles the case where we're checking access to a parent directory
// when a subdirectory is explicitly allowed
if strings.HasPrefix(basePattern, normalizedPath) &&
(strings.HasSuffix(resolvedPattern, "/**") ||
strings.HasSuffix(resolvedPattern, "/**/*") ||
strings.HasSuffix(resolvedPattern, "/*")) {
return true
}
} else {
// For files, check if any parent directory is allowed with wildcards
parentDir := filepath.Dir(normalizedPath)
if !strings.HasSuffix(parentDir, "/") {
parentDir += "/"
}
// Check if the file's parent directory matches a directory wildcard pattern
for _, suffix := range []string{"/**/*", "/**", "/*"} {
if strings.HasSuffix(resolvedPattern, suffix) {
basePattern := strings.TrimSuffix(resolvedPattern, suffix)
if !strings.HasSuffix(basePattern, "/") {
basePattern += "/"
}
if strings.HasPrefix(parentDir, basePattern) {
return true
}
}
}
}
}
}
// No matching pattern found, deny access
return false
}
// resolvePattern resolves environment variables and special placeholders in a pattern
// Returns a slice of resolved patterns to account for placeholders that can expand to multiple paths
func (a *AppContextImpl) resolvePattern(pattern string) []string {
// Start with the original pattern
patterns := []string{pattern}
// Replace special placeholders with their actual values
placeholders := []string{"$SEANIME_ANIME_LIBRARY", "$HOME", "$CACHE", "$TEMP", "$CONFIG"}
for _, placeholder := range placeholders {
// Extract the placeholder name without the $ prefix
name := strings.TrimPrefix(placeholder, "$")
paths := a.resolveEnvironmentPaths(name)
if len(paths) == 0 {
continue
}
// If the placeholder exists in the pattern and expands to multiple paths,
// we need to create multiple patterns
if strings.Contains(pattern, placeholder) && len(paths) > 1 {
// Create a new set of patterns for each path
newPatterns := []string{}
for _, existingPattern := range patterns {
for _, path := range paths {
// Ensure proper path separator handling
cleanPath := filepath.ToSlash(path)
// Replace the placeholder with the path, ensuring no double slashes
newPattern := strings.ReplaceAll(existingPattern, placeholder, cleanPath)
newPatterns = append(newPatterns, newPattern)
}
}
// Replace the old patterns with the new ones
patterns = newPatterns
} else if len(paths) > 0 {
// If there's only one path or the placeholder doesn't exist,
// just replace it in all existing patterns
for i := range patterns {
// Ensure proper path separator handling
cleanPath := filepath.ToSlash(paths[0])
// Replace the placeholder with the path, ensuring no double slashes
patterns[i] = strings.ReplaceAll(patterns[i], placeholder, cleanPath)
}
}
}
// Replace environment variables in all patterns
for i := range patterns {
patterns[i] = os.ExpandEnv(patterns[i])
}
// Clean up any potential double slashes that might have been introduced
for i := range patterns {
// Replace any double slashes with single slashes
for strings.Contains(patterns[i], "//") {
patterns[i] = strings.ReplaceAll(patterns[i], "//", "/")
}
}
return patterns
}
func (a *AppContextImpl) isAllowedCommand(ext *extension.Extension, name string, arg ...string) bool {
// If the extension doesn't have a plugin manifest or system allowlist, deny access
if ext == nil || ext.Plugin == nil {
return false
}
// Get the system allowlist
allowlist := ext.Plugin.Permissions.Allow
// Check if the command is allowed in any of the command scopes
for _, scope := range allowlist.CommandScopes {
// Check if the command name matches
if scope.Command != name {
continue
}
// If no args are defined in the scope but args are provided, deny access
if len(scope.Args) == 0 && len(arg) > 0 {
continue
}
// Check if the arguments match the allowed pattern
if !a.validateCommandArgs(ext, scope.Args, arg) {
continue
}
// Command and args match the scope, allow access
return true
}
// No matching command scope found, deny access
return false
}
// validateCommandArgs checks if the provided arguments match the allowed pattern
func (a *AppContextImpl) validateCommandArgs(ext *extension.Extension, allowedArgs []extension.CommandArg, providedArgs []string) bool {
// If more args are provided than allowed, deny access
if len(providedArgs) > len(allowedArgs) {
if len(allowedArgs) > 0 && allowedArgs[len(allowedArgs)-1].Validator == "$ARGS" {
return true
}
return false
}
// Check each argument
for i, allowedArg := range allowedArgs {
// If we've reached the end of the provided args, deny access
if i >= len(providedArgs) {
return false
}
// If the argument has a fixed value, check if it matches exactly
if allowedArg.Value != "" {
if allowedArg.Value != providedArgs[i] {
return false
}
continue
}
// If the argument has a validator, check if it matches
if allowedArg.Validator != "" {
// Special case: $ARGS allows any value for the rest of the arguments
if allowedArg.Validator == "$ARGS" {
return true
}
// Special case: $PATH allows any valid file path
if allowedArg.Validator == "$PATH" {
// Simple path validation - could be enhanced
if providedArgs[i] == "" {
return false
}
// Check if the path is allowed
if !a.isAllowedPath(ext, providedArgs[i], AllowPathWrite) {
return false
}
// Check if the path exists
if _, err := os.Stat(providedArgs[i]); os.IsNotExist(err) {
return false
}
continue
}
// Use regex validation for other validators
matched, err := regexp.MatchString(allowedArg.Validator, providedArgs[i])
if err != nil || !matched {
return false
}
continue
}
// If neither value nor validator is specified, deny access
return false
}
return true
}
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// GetCommand returns the underlying exec.Cmd
func (c *AsyncCmd) GetCommand() *exec.Cmd {
return c.cmd
}
func (c *AsyncCmd) Run(callback goja.Callable) error {
stdout, err := c.cmd.StdoutPipe()
if err != nil {
return err
}
stderr, err := c.cmd.StderrPipe()
if err != nil {
return err
}
// Start the command
err = c.cmd.Start()
if err != nil {
return err
}
// Use WaitGroup to ensure all goroutines complete before Wait() is called
var wg sync.WaitGroup
wg.Add(2) // One for stdout, one for stderr
// Handle stdout
go func() {
defer wg.Done()
scanner := bufio.NewScanner(stdout)
for scanner.Scan() {
data := scanner.Bytes()
dataBytes := make([]byte, len(data))
copy(dataBytes, data)
c.scheduler.ScheduleAsync(func() error {
_, err := callback(goja.Undefined(), c.vm.ToValue(dataBytes), goja.Undefined(), goja.Undefined(), goja.Undefined())
return err
})
}
}()
// Handle stderr
go func() {
defer wg.Done()
scanner := bufio.NewScanner(stderr)
for scanner.Scan() {
data := scanner.Bytes()
dataBytes := make([]byte, len(data))
copy(dataBytes, data)
c.scheduler.ScheduleAsync(func() error {
_, err := callback(goja.Undefined(), goja.Undefined(), c.vm.ToValue(dataBytes), goja.Undefined(), goja.Undefined())
return err
})
}
}()
// Wait for both stdout and stderr to be fully processed in a separate goroutine
go func() {
// Wait for stdout and stderr goroutines to complete
wg.Wait()
// Now wait for the command to finish
err := c.cmd.Wait()
_ = stdout.Close()
_ = stderr.Close()
// Process exit code and signal
exitCode := 0
signal := ""
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
exitCode = exitErr.ExitCode()
signal = exitErr.String()
} else if c.cmd.ProcessState != nil {
exitCode = c.cmd.ProcessState.ExitCode()
signal = c.cmd.ProcessState.String()
}
// Call callback with exit code and signal
c.scheduler.ScheduleAsync(func() error {
_, err = callback(goja.Undefined(), goja.Undefined(), goja.Undefined(), c.vm.ToValue(exitCode), c.vm.ToValue(signal))
return err
})
}()
return nil
}
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
func (c *CmdHelper) Run(callback goja.Callable) error {
return nil
}
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// // OnData registers a callback to be called when data is available from the command's stdout
// func (c *AsyncCmd) OnData(callback func(data []byte)) error {
// stdout, err := c.cmd.StdoutPipe()
// if err != nil {
// return err
// }
// go func() {
// scanner := bufio.NewScanner(stdout)
// for scanner.Scan() {
// c.scheduler.ScheduleAsync(func() error {
// callback(scanner.Bytes())
// return nil
// })
// }
// }()
// return nil
// }
// // OnError registers a callback to be called when data is available from the command's stderr
// func (c *AsyncCmd) OnError(callback func(data []byte)) error {
// stderr, err := c.cmd.StderrPipe()
// if err != nil {
// return err
// }
// go func() {
// scanner := bufio.NewScanner(stderr)
// for scanner.Scan() {
// c.scheduler.ScheduleAsync(func() error {
// callback(scanner.Bytes())
// return nil
// })
// }
// }()
// return nil
// }
// // OnExit registers a callback to be called when the command exits
// func (c *AsyncCmd) OnExit(callback func(code int, signal string)) error {
// go func() {
// err := c.cmd.Wait()
// if err != nil {
// return
// }
// c.scheduler.ScheduleAsync(func() error {
// callback(c.cmd.ProcessState.ExitCode(), c.cmd.ProcessState.String())
// return nil
// })
// }()
// return nil
// }