diff --git a/core/moonshark.go b/core/moonshark.go index 26ef03b..2c36f18 100644 --- a/core/moonshark.go +++ b/core/moonshark.go @@ -176,6 +176,7 @@ func (s *Moonshark) initRunner() error { runnerOpts := []runner.RunnerOption{ runner.WithPoolSize(s.Config.Runner.PoolSize), runner.WithLibDirs(s.Config.Dirs.Libs...), + runner.WithFsDir(s.Config.Dirs.FS), runner.WithDataDir(s.Config.Dirs.Data), } diff --git a/core/runner/embed.go b/core/runner/embed.go index 98df9a9..55569c3 100644 --- a/core/runner/embed.go +++ b/core/runner/embed.go @@ -19,14 +19,19 @@ var jsonLuaCode string //go:embed sqlite.lua var sqliteLuaCode string +//go:embed fs.lua +var fsLuaCode string + // Global bytecode cache to improve performance var ( sandboxBytecode atomic.Pointer[[]byte] jsonBytecode atomic.Pointer[[]byte] sqliteBytecode atomic.Pointer[[]byte] + fsBytecode atomic.Pointer[[]byte] bytecodeOnce sync.Once jsonBytecodeOnce sync.Once sqliteBytecodeOnce sync.Once + fsBytecodeOnce sync.Once ) // precompileSandboxCode compiles the sandbox.lua code to bytecode once @@ -95,6 +100,27 @@ func precompileSqliteModule() { logger.Debug("Successfully precompiled sqlite.lua to bytecode (%d bytes)", len(code)) } +func precompileFsModule() { + tempState := luajit.New() + if tempState == nil { + logger.Fatal("Failed to create temp Lua state for FS module compilation") + } + defer tempState.Close() + defer tempState.Cleanup() + + code, err := tempState.CompileBytecode(fsLuaCode, "fs.lua") + if err != nil { + logger.Error("Failed to compile FS module: %v", err) + return + } + + bytecode := make([]byte, len(code)) + copy(bytecode, code) + fsBytecode.Store(&bytecode) + + logger.Debug("Successfully precompiled fs.lua to bytecode (%d bytes)", len(code)) +} + // loadSandboxIntoState loads the sandbox code into a Lua state func loadSandboxIntoState(state *luajit.State, verbose bool) error { bytecodeOnce.Do(precompileSandboxCode) @@ -158,6 +184,32 @@ func loadSandboxIntoState(state *luajit.State, verbose bool) error { } } + fsBytecodeOnce.Do(precompileFsModule) + fsBytecode := fsBytecode.Load() + if fsBytecode != nil && len(*fsBytecode) > 0 { + if verbose { + logger.Debug("Loading fs.lua from precompiled bytecode") + } + + if err := state.LoadBytecode(*fsBytecode, "fs.lua"); err != nil { + return err + } + + if err := state.RunBytecodeWithResults(1); err != nil { + return err + } + + state.SetGlobal("fs") + } else { + if verbose { + logger.Warning("Using non-precompiled fs.lua") + } + + if err := state.DoString(fsLuaCode); err != nil { + return err + } + } + bytecode := sandboxBytecode.Load() if bytecode != nil && len(*bytecode) > 0 { if verbose { diff --git a/core/runner/fs.go b/core/runner/fs.go new file mode 100644 index 0000000..25c3e55 --- /dev/null +++ b/core/runner/fs.go @@ -0,0 +1,508 @@ +package runner + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "Moonshark/core/utils/logger" + + luajit "git.sharkk.net/Sky/LuaJIT-to-Go" +) + +// Global filesystem path (set during initialization) +var fsBasePath string + +// InitFS initializes the filesystem with the given base path +func InitFS(basePath string) error { + if basePath == "" { + return errors.New("filesystem base path cannot be empty") + } + + // Create the directory if it doesn't exist + if err := os.MkdirAll(basePath, 0755); err != nil { + return fmt.Errorf("failed to create filesystem directory: %w", err) + } + + // Store the absolute path + absPath, err := filepath.Abs(basePath) + if err != nil { + return fmt.Errorf("failed to get absolute path: %w", err) + } + + fsBasePath = absPath + logger.Server("Virtual filesystem initialized at: %s", fsBasePath) + return nil +} + +// CleanupFS performs any necessary cleanup +func CleanupFS() { + // Nothing to clean up currently +} + +// ResolvePath resolves a given path relative to the filesystem base +// Returns the actual path and an error if the path tries to escape the sandbox +func ResolvePath(path string) (string, error) { + if fsBasePath == "" { + return "", errors.New("filesystem not initialized") + } + + // Clean the path to remove any .. or . components + cleanPath := filepath.Clean(path) + + // Replace backslashes with forward slashes for consistent handling + cleanPath = strings.ReplaceAll(cleanPath, "\\", "/") + + // Remove any leading / or drive letter to make it relative + cleanPath = strings.TrimPrefix(cleanPath, "/") + + // Remove drive letter on Windows (e.g. C:) + if len(cleanPath) >= 2 && cleanPath[1] == ':' { + cleanPath = cleanPath[2:] + } + + // Ensure the path doesn't contain .. to prevent escaping + if strings.Contains(cleanPath, "..") { + return "", errors.New("path cannot contain .. components") + } + + // Join with the base path + fullPath := filepath.Join(fsBasePath, cleanPath) + + // Verify the path is still within the base directory + if !strings.HasPrefix(fullPath, fsBasePath) { + return "", errors.New("path escapes the filesystem sandbox") + } + + return fullPath, nil +} + +// fsReadFile reads a file and returns its contents +func fsReadFile(state *luajit.State) int { + if !state.IsString(1) { + state.PushString("fs.read_file: path must be a string") + return -1 + } + path := state.ToString(1) + + fullPath, err := ResolvePath(path) + if err != nil { + state.PushString("fs.read_file: " + err.Error()) + return -1 + } + + data, err := os.ReadFile(fullPath) + if err != nil { + state.PushString("fs.read_file: " + err.Error()) + return -1 + } + + state.PushString(string(data)) + return 1 +} + +// fsWriteFile writes data to a file +func fsWriteFile(state *luajit.State) int { + if !state.IsString(1) { + state.PushString("fs.write_file: path must be a string") + return -1 + } + path := state.ToString(1) + + if !state.IsString(2) { + state.PushString("fs.write_file: content must be a string") + return -1 + } + content := state.ToString(2) + + fullPath, err := ResolvePath(path) + if err != nil { + state.PushString("fs.write_file: " + err.Error()) + return -1 + } + + // Ensure the directory exists + dir := filepath.Dir(fullPath) + if err := os.MkdirAll(dir, 0755); err != nil { + state.PushString("fs.write_file: failed to create directory: " + err.Error()) + return -1 + } + + err = os.WriteFile(fullPath, []byte(content), 0644) + if err != nil { + state.PushString("fs.write_file: " + err.Error()) + return -1 + } + + state.PushBoolean(true) + return 1 +} + +// fsAppendFile appends data to a file +func fsAppendFile(state *luajit.State) int { + if !state.IsString(1) { + state.PushString("fs.append_file: path must be a string") + return -1 + } + path := state.ToString(1) + + if !state.IsString(2) { + state.PushString("fs.append_file: content must be a string") + return -1 + } + content := state.ToString(2) + + fullPath, err := ResolvePath(path) + if err != nil { + state.PushString("fs.append_file: " + err.Error()) + return -1 + } + + // Ensure the directory exists + dir := filepath.Dir(fullPath) + if err := os.MkdirAll(dir, 0755); err != nil { + state.PushString("fs.append_file: failed to create directory: " + err.Error()) + return -1 + } + + file, err := os.OpenFile(fullPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + state.PushString("fs.append_file: " + err.Error()) + return -1 + } + defer file.Close() + + _, err = file.Write([]byte(content)) + if err != nil { + state.PushString("fs.append_file: " + err.Error()) + return -1 + } + + state.PushBoolean(true) + return 1 +} + +// fsExists checks if a file or directory exists +func fsExists(state *luajit.State) int { + if !state.IsString(1) { + state.PushString("fs.exists: path must be a string") + return -1 + } + path := state.ToString(1) + + fullPath, err := ResolvePath(path) + if err != nil { + state.PushString("fs.exists: " + err.Error()) + return -1 + } + + _, err = os.Stat(fullPath) + state.PushBoolean(err == nil) + return 1 +} + +// fsRemoveFile removes a file +func fsRemoveFile(state *luajit.State) int { + if !state.IsString(1) { + state.PushString("fs.remove_file: path must be a string") + return -1 + } + path := state.ToString(1) + + fullPath, err := ResolvePath(path) + if err != nil { + state.PushString("fs.remove_file: " + err.Error()) + return -1 + } + + // Check if it's a directory + info, err := os.Stat(fullPath) + if err != nil { + state.PushString("fs.remove_file: " + err.Error()) + return -1 + } + + if info.IsDir() { + state.PushString("fs.remove_file: cannot remove directory, use remove_dir instead") + return -1 + } + + err = os.Remove(fullPath) + if err != nil { + state.PushString("fs.remove_file: " + err.Error()) + return -1 + } + + state.PushBoolean(true) + return 1 +} + +// fsGetInfo gets information about a file +func fsGetInfo(state *luajit.State) int { + if !state.IsString(1) { + state.PushString("fs.get_info: path must be a string") + return -1 + } + path := state.ToString(1) + + fullPath, err := ResolvePath(path) + if err != nil { + state.PushString("fs.get_info: " + err.Error()) + return -1 + } + + info, err := os.Stat(fullPath) + if err != nil { + state.PushString("fs.get_info: " + err.Error()) + return -1 + } + + state.NewTable() + + state.PushString(info.Name()) + state.SetField(-2, "name") + + state.PushNumber(float64(info.Size())) + state.SetField(-2, "size") + + state.PushNumber(float64(info.Mode())) + state.SetField(-2, "mode") + + state.PushNumber(float64(info.ModTime().Unix())) + state.SetField(-2, "mod_time") + + state.PushBoolean(info.IsDir()) + state.SetField(-2, "is_dir") + + return 1 +} + +// fsMakeDir creates a directory +func fsMakeDir(state *luajit.State) int { + if !state.IsString(1) { + state.PushString("fs.make_dir: path must be a string") + return -1 + } + path := state.ToString(1) + + perm := os.FileMode(0755) + if state.GetTop() >= 2 && state.IsNumber(2) { + perm = os.FileMode(state.ToNumber(2)) + } + + fullPath, err := ResolvePath(path) + if err != nil { + state.PushString("fs.make_dir: " + err.Error()) + return -1 + } + + err = os.MkdirAll(fullPath, perm) + if err != nil { + state.PushString("fs.make_dir: " + err.Error()) + return -1 + } + + state.PushBoolean(true) + return 1 +} + +// fsListDir lists the contents of a directory +func fsListDir(state *luajit.State) int { + if !state.IsString(1) { + state.PushString("fs.list_dir: path must be a string") + return -1 + } + path := state.ToString(1) + + fullPath, err := ResolvePath(path) + if err != nil { + state.PushString("fs.list_dir: " + err.Error()) + return -1 + } + + info, err := os.Stat(fullPath) + if err != nil { + state.PushString("fs.list_dir: " + err.Error()) + return -1 + } + + if !info.IsDir() { + state.PushString("fs.list_dir: not a directory") + return -1 + } + + files, err := os.ReadDir(fullPath) + if err != nil { + state.PushString("fs.list_dir: " + err.Error()) + return -1 + } + + state.NewTable() + + for i, file := range files { + state.PushNumber(float64(i + 1)) + state.PushString(file.Name()) + state.SetTable(-3) + } + + return 1 +} + +// fsRemoveDir removes a directory +func fsRemoveDir(state *luajit.State) int { + if !state.IsString(1) { + state.PushString("fs.remove_dir: path must be a string") + return -1 + } + path := state.ToString(1) + + recursive := false + if state.GetTop() >= 2 { + recursive = state.ToBoolean(2) + } + + fullPath, err := ResolvePath(path) + if err != nil { + state.PushString("fs.remove_dir: " + err.Error()) + return -1 + } + + info, err := os.Stat(fullPath) + if err != nil { + state.PushString("fs.remove_dir: " + err.Error()) + return -1 + } + + if !info.IsDir() { + state.PushString("fs.remove_dir: not a directory") + return -1 + } + + if recursive { + err = os.RemoveAll(fullPath) + } else { + err = os.Remove(fullPath) + } + + if err != nil { + state.PushString("fs.remove_dir: " + err.Error()) + return -1 + } + + state.PushBoolean(true) + return 1 +} + +// fsJoinPaths joins path components +func fsJoinPaths(state *luajit.State) int { + nargs := state.GetTop() + if nargs < 1 { + state.PushString("fs.join_paths: at least one path component required") + return -1 + } + + components := make([]string, nargs) + for i := 1; i <= nargs; i++ { + if !state.IsString(i) { + state.PushString("fs.join_paths: all arguments must be strings") + return -1 + } + components[i-1] = state.ToString(i) + } + + result := filepath.Join(components...) + result = strings.ReplaceAll(result, "\\", "/") + + state.PushString(result) + return 1 +} + +// fsDirName returns the directory portion of a path +func fsDirName(state *luajit.State) int { + if !state.IsString(1) { + state.PushString("fs.dir_name: path must be a string") + return -1 + } + path := state.ToString(1) + + dir := filepath.Dir(path) + dir = strings.ReplaceAll(dir, "\\", "/") + + state.PushString(dir) + return 1 +} + +// fsBaseName returns the file name portion of a path +func fsBaseName(state *luajit.State) int { + if !state.IsString(1) { + state.PushString("fs.base_name: path must be a string") + return -1 + } + path := state.ToString(1) + + base := filepath.Base(path) + + state.PushString(base) + return 1 +} + +// fsExtension returns the file extension +func fsExtension(state *luajit.State) int { + if !state.IsString(1) { + state.PushString("fs.extension: path must be a string") + return -1 + } + path := state.ToString(1) + + ext := filepath.Ext(path) + + state.PushString(ext) + return 1 +} + +// RegisterFSFunctions registers filesystem functions with the Lua state +func RegisterFSFunctions(state *luajit.State) error { + if err := state.RegisterGoFunction("__fs_read_file", fsReadFile); err != nil { + return err + } + if err := state.RegisterGoFunction("__fs_write_file", fsWriteFile); err != nil { + return err + } + if err := state.RegisterGoFunction("__fs_append_file", fsAppendFile); err != nil { + return err + } + if err := state.RegisterGoFunction("__fs_exists", fsExists); err != nil { + return err + } + if err := state.RegisterGoFunction("__fs_remove_file", fsRemoveFile); err != nil { + return err + } + if err := state.RegisterGoFunction("__fs_get_info", fsGetInfo); err != nil { + return err + } + if err := state.RegisterGoFunction("__fs_make_dir", fsMakeDir); err != nil { + return err + } + if err := state.RegisterGoFunction("__fs_list_dir", fsListDir); err != nil { + return err + } + if err := state.RegisterGoFunction("__fs_remove_dir", fsRemoveDir); err != nil { + return err + } + if err := state.RegisterGoFunction("__fs_join_paths", fsJoinPaths); err != nil { + return err + } + if err := state.RegisterGoFunction("__fs_dir_name", fsDirName); err != nil { + return err + } + if err := state.RegisterGoFunction("__fs_base_name", fsBaseName); err != nil { + return err + } + if err := state.RegisterGoFunction("__fs_extension", fsExtension); err != nil { + return err + } + + return nil +} diff --git a/core/runner/fs.lua b/core/runner/fs.lua new file mode 100644 index 0000000..21cb875 --- /dev/null +++ b/core/runner/fs.lua @@ -0,0 +1,139 @@ +local fs = {} + +-- File Operations +fs.read_file = function(path) + if type(path) ~= "string" then + error("fs.read_file: path must be a string", 2) + end + return __fs_read_file(path) +end + +fs.write_file = function(path, content) + if type(path) ~= "string" then + error("fs.write_file: path must be a string", 2) + end + if type(content) ~= "string" then + error("fs.write_file: content must be a string", 2) + end + return __fs_write_file(path, content) +end + +fs.append_file = function(path, content) + if type(path) ~= "string" then + error("fs.append_file: path must be a string", 2) + end + if type(content) ~= "string" then + error("fs.append_file: content must be a string", 2) + end + return __fs_append_file(path, content) +end + +fs.exists = function(path) + if type(path) ~= "string" then + error("fs.exists: path must be a string", 2) + end + return __fs_exists(path) +end + +fs.remove_file = function(path) + if type(path) ~= "string" then + error("fs.remove_file: path must be a string", 2) + end + return __fs_remove_file(path) +end + +fs.get_info = function(path) + if type(path) ~= "string" then + error("fs.get_info: path must be a string", 2) + end + local info = __fs_get_info(path) + + -- Convert the Unix timestamp to a readable date + if info and info.mod_time then + info.mod_time_str = os.date("%Y-%m-%d %H:%M:%S", info.mod_time) + end + + return info +end + +-- Directory Operations +fs.make_dir = function(path, mode) + if type(path) ~= "string" then + error("fs.make_dir: path must be a string", 2) + end + mode = mode or 0755 + return __fs_make_dir(path, mode) +end + +fs.list_dir = function(path) + if type(path) ~= "string" then + error("fs.list_dir: path must be a string", 2) + end + return __fs_list_dir(path) +end + +fs.remove_dir = function(path, recursive) + if type(path) ~= "string" then + error("fs.remove_dir: path must be a string", 2) + end + recursive = recursive or false + return __fs_remove_dir(path, recursive) +end + +-- Path Operations +fs.join_paths = function(...) + return __fs_join_paths(...) +end + +fs.dir_name = function(path) + if type(path) ~= "string" then + error("fs.dir_name: path must be a string", 2) + end + return __fs_dir_name(path) +end + +fs.base_name = function(path) + if type(path) ~= "string" then + error("fs.base_name: path must be a string", 2) + end + return __fs_base_name(path) +end + +fs.extension = function(path) + if type(path) ~= "string" then + error("fs.extension: path must be a string", 2) + end + return __fs_extension(path) +end + +-- Utility Functions +fs.read_json = function(path) + local content = fs.read_file(path) + if not content then + return nil, "Could not read file" + end + + local ok, result = pcall(json.decode, content) + if not ok then + return nil, "Invalid JSON: " .. tostring(result) + end + + return result +end + +fs.write_json = function(path, data, pretty) + if type(data) ~= "table" then + error("fs.write_json: data must be a table", 2) + end + + local content + if pretty then + content = json.pretty_print(data) + else + content = json.encode(data) + end + + return fs.write_file(path, content) +end + +return fs diff --git a/core/runner/runner.go b/core/runner/runner.go index b2b5a24..69135bf 100644 --- a/core/runner/runner.go +++ b/core/runner/runner.go @@ -39,6 +39,7 @@ type Runner struct { poolSize int // Size of the state pool moduleLoader *ModuleLoader // Module loader dataDir string // Data directory for SQLite databases + fsDir string // Virtual filesystem directory isRunning atomic.Bool // Whether the runner is active mu sync.RWMutex // Mutex for thread safety scriptDir string // Current script directory @@ -75,12 +76,22 @@ func WithDataDir(dataDir string) RunnerOption { } } +// WithFsDir sets the virtual filesystem directory +func WithFsDir(fsDir string) RunnerOption { + return func(r *Runner) { + if fsDir != "" { + r.fsDir = fsDir + } + } +} + // NewRunner creates a new Runner with a pool of states func NewRunner(options ...RunnerOption) (*Runner, error) { // Default configuration runner := &Runner{ poolSize: runtime.GOMAXPROCS(0), dataDir: "data", + fsDir: "fs", } // Apply options @@ -97,8 +108,8 @@ func NewRunner(options ...RunnerOption) (*Runner, error) { runner.moduleLoader = NewModuleLoader(config) } - // Initialize SQLite InitSQLite(runner.dataDir) + InitFS(runner.fsDir) // Initialize states and pool runner.states = make([]*State, runner.poolSize) @@ -273,7 +284,7 @@ cleanup: } } - // Clean up SQLite + CleanupFS() CleanupSQLite() logger.Debug("Runner closed") diff --git a/core/runner/sandbox.go b/core/runner/sandbox.go index f55606b..0ed0814 100644 --- a/core/runner/sandbox.go +++ b/core/runner/sandbox.go @@ -105,6 +105,10 @@ func (s *Sandbox) registerCoreFunctions(state *luajit.State) error { return err } + if err := RegisterFSFunctions(state); err != nil { + return err + } + return nil }