diff --git a/pkg/KFData/KFFS/KFFS.go b/pkg/KFData/KFFS/KFFS.go new file mode 100644 index 0000000..cb3e33f --- /dev/null +++ b/pkg/KFData/KFFS/KFFS.go @@ -0,0 +1,363 @@ +// This package allows for grouping embedded filesystems into one multiFS, +// and provides a simple interface for reading from them +// and protections for reading from the local game filesystem +package KFFS + +import ( + "embed" + "errors" + "io/fs" + "log" + "os" + "path/filepath" + "strconv" + "strings" +) + +// TODO: add in some more security features to prevent directory traversal attacks and other security vulnerabilities. +// this package should be an easy to use viable alternative to the standard os and fs packages, while providing +// some ease of mind for end user security. + +var localFS fs.FS +var localIsValid bool + +// IsRunningDebug TODO: make sure this is removed before any production builds +func IsRunningDebug() bool { + return os.Getenv("DEBUG_RUN") == "true" +} + +func init() { + ex, err := os.Executable() + if err != nil { + localIsValid = false + return + } + + // check if there is a game directory at the root of the executable + dataDir := filepath.Join(filepath.Dir(ex), "data") + if IsRunningDebug() { + dataDir, err = filepath.Abs("data") + if err != nil { + localIsValid = false + return + } + } + + info, err := os.Stat(dataDir) + if err != nil { + localIsValid = false + return + } + + // check if it is a directory + if !info.IsDir() { + localIsValid = false + return + } + + localFS = os.DirFS(dataDir) + + // DEBUG: REMOVE BELOW + // walk the directory to check if it is valid by printing out the files + err = fs.WalkDir(localFS, ".", func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + log.Println(path) + + return nil + }) + if err != nil { + localIsValid = false + return + } + // DEBUG: REMOVE ABOVE + + localIsValid = true +} + +// Configuration is a configuration struct for the multiFS that allows specifying the filesystems to use. +// new file systems can be added by calling EmbedFS or and embed.FS or a fs.FS +type Configuration struct { + FSNames []string + LocalFS bool + OnlyLocal bool +} + +// NewConfiguration creates a new configuration for the multiFS if local is true is checked first. +// by leaving fsNames empty all filesystems will be checked in no particular order (it loops through a map[string]fs.FS), +// with most fuctions except walk. +// returning the first matching file, Walk will walk all filesystems if it is called by an empty fsNames, +// or only the specified filesystems if fsNames is not empty. +// you can also manually set OnlyLocal to true after creating the configuration to only check the local filesystem. +func NewConfiguration(useLocal bool, fsNames ...string) Configuration { + return Configuration{ + LocalFS: useLocal, + FSNames: fsNames, + } +} + +// multiFS is a list of filesystems. +type multiFS map[string]fs.FS + +func (m multiFS) Open(path string, config Configuration) (fs.File, error) { + path = filepath.Clean(path) + path = strings.ReplaceAll(path, "\\", "/") + if (config.LocalFS || config.OnlyLocal) && localIsValid { + file, err := localFS.Open(path) + if err == nil { + return file, nil + } + } else if !localIsValid && config.OnlyLocal { + return nil, errors.New("local filesystem is not valid") + } + + if !config.OnlyLocal { + if len(config.FSNames) > 0 { + for _, name := range config.FSNames { + // check if the name exists in the filesystems + fsys, ok := m[name] + if !ok { + continue + } + file, err := fsys.Open(path) + if err == nil { + return file, nil + } + } + } else { + for _, fsys := range m { + file, err := fsys.Open(path) + if err == nil { + return file, nil + } + } + } + } + + return nil, os.ErrNotExist +} + +// Stat returns the fileInfo for the first matching file info in the filesystem. +func (m multiFS) Stat(path string, config Configuration) (fs.FileInfo, error) { + path = filepath.Clean(path) + path = strings.ReplaceAll(path, "\\", "/") + if (config.LocalFS || config.OnlyLocal) && localIsValid { + fileInfo, err := fs.Stat(localFS, path) + if err == nil { + return fileInfo, nil + } + } else if !localIsValid && config.OnlyLocal { + return nil, errors.New("local filesystem is not valid") + } + + if !config.OnlyLocal { + if len(config.FSNames) > 0 { + for _, name := range config.FSNames { + // check if the name exists in the filesystems + fsys, ok := m[name] + if !ok { + continue + } + file, err := fsys.Open(path) + if err == nil { + fileInfo, err := file.Stat() + if err == nil { + return fileInfo, nil + } + } + } + } else { + for _, fsys := range m { + file, err := fsys.Open(path) + if err == nil { + fileInfo, err := file.Stat() + if err == nil { + return fileInfo, nil + } + } + } + } + } + + return nil, os.ErrNotExist +} + +// ReadFile reads the first matching file in the filesystems and returns the contents as a byte slice +func (m multiFS) ReadFile(path string, config Configuration) ([]byte, error) { + path = filepath.Clean(path) + path = strings.ReplaceAll(path, "\\", "/") + if (config.LocalFS || config.OnlyLocal) && localIsValid { + data, err := fs.ReadFile(localFS, path) + if err == nil { + return data, nil + } + } else if !localIsValid && config.OnlyLocal { + return nil, errors.New("local filesystem is not valid") + } + if !config.OnlyLocal { + if len(config.FSNames) > 0 { + for _, name := range config.FSNames { + //Check if the name exists in the filesystems + fsys, ok := m[name] + if !ok { + continue + } + data, err := fs.ReadFile(fsys, path) + if err == nil { + return data, nil + } + } + } else { + for _, fsys := range m { + data, err := fs.ReadFile(fsys, path) + if err == nil { + return data, nil + } + } + } + } + return nil, os.ErrNotExist +} + +// ReadDir reads the first matching directory in the filesystems and returns a list of directory entries +func (m multiFS) ReadDir(path string, config Configuration) ([]fs.DirEntry, error) { + path = filepath.Clean(path) + path = strings.ReplaceAll(path, "\\", "/") + if (config.LocalFS || config.OnlyLocal) && localIsValid { + data, err := fs.ReadDir(localFS, path) + if err == nil { + return data, nil + } + } else if !localIsValid && config.OnlyLocal { + return nil, errors.New("local filesystem is not valid") + } + if !config.OnlyLocal { + if len(config.FSNames) > 0 { + for _, name := range config.FSNames { + //Check if the name exists in the filesystems + fsys, ok := m[name] + if !ok { + continue + } + data, err := fs.ReadDir(fsys, path) + if err == nil { + return data, nil + } + } + } else { + for _, fsys := range m { + data, err := fs.ReadDir(fsys, path) + if err == nil { + return data, nil + } + } + } + } + return nil, os.ErrNotExist +} + +// Walk walks each filesystem in the multiFS performing the walkFn on each file or directory +func (m multiFS) Walk(path string, walkFn fs.WalkDirFunc, config Configuration) error { + path = filepath.Clean(path) + path = strings.ReplaceAll(path, "\\", "/") + var err error + if (config.LocalFS || config.OnlyLocal) && localIsValid { + walkErr := fs.WalkDir(localFS, path, walkFn) + if walkErr != nil { + errors.Join(err, walkErr) + } + } else if !localIsValid && config.OnlyLocal { + errors.Join(err, errors.New("local filesystem is not valid")) + } + if !config.OnlyLocal { + if len(config.FSNames) > 0 { + for _, name := range config.FSNames { + // check if the name exists in the filesystems + fsys, ok := m[name] + if !ok { + continue + } + walkErr := fs.WalkDir(fsys, path, walkFn) + if walkErr != nil { + errors.Join(err, walkErr) + } + } + } else { + for _, fsys := range m { + walkErr := fs.WalkDir(fsys, path, walkFn) + if walkErr != nil { + errors.Join(err, walkErr) + } + } + } + } + return err +} + +var embeddedFS multiFS + +// EmbedFS sets the embedded filesystem to use for loading files +// This function can be called multiple times to add multiple embedded filesystems +// However, please note that unless you use Walk, the functions acting on these added filesystems +// will only return the first matching result. So make sure your filenames/directory names are unique between the filesystems +// Or specify the filesystem to use when calling the function +func EmbedFS(fs embed.FS, names ...string) { + //Check if a name is provided otherwise set name to "embedded" plus a number until it is unique + name := "embedded" + if len(names) > 0 { + name = names[0] + } + for i := 0; embeddedFS[name] != nil; i++ { + name = "embedded" + strconv.Itoa(i) + } + embeddedFS[name] = fs +} + +// Walk is an extension of fs.WalkDir for any specified fileSystems +// that have been embedded with EmbedFS +// or all of them if no names are specified(It will return the first matching file) +// and optionally the local game filesystem +// Configuration can be created with NewConfiguration +func Walk(dir string, config Configuration, walkFn fs.WalkDirFunc) error { + return embeddedFS.Walk(dir, walkFn, config) +} + +// ReadFile is an extension of fs.ReadFile for any specified fileSystems +// that have been embedded with EmbedFS +// or all of them if no names are specified(It will return the first matching file) +// and optionally the local game filesystem +// Configuration can be created with NewConfiguration +func ReadFile(path string, config Configuration) ([]byte, error) { + return embeddedFS.ReadFile(path, config) +} + +// ReadDir is an extension of fs.ReadDir for any specified fileSystems +// that have been embedded with EmbedFS +// or all of them if no names are specified(It will return the first matching file) +// and optionally the local game filesystem +// Configuration can be created with NewConfiguration +func ReadDir(path string, config Configuration) ([]fs.DirEntry, error) { + return embeddedFS.ReadDir(path, config) +} + +// Open is an extension of os.Open for any specified fileSystems +// that have been embedded with EmbedFS +// or all of them if no names are specified(It will return the first matching file) +// and optionally the local game filesystem +// Configuration can be created with NewConfiguration +func Open(path string, config Configuration) (fs.File, error) { + return embeddedFS.Open(path, config) +} + +// Stat is an extension of fs.Stat for any specified fileSystems +// that have been embedded with EmbedFS +// or all of them if no names are specified(It will return the first matching file) +// and optionally the local game filesystem +// Configuration can be created with NewConfiguration +func Stat(path string, config Configuration) (fs.FileInfo, error) { + return embeddedFS.Stat(path, config) +}