diff --git a/pkg/core/core.go b/pkg/core/core.go index 24c8bfe..3c5c181 100644 --- a/pkg/core/core.go +++ b/pkg/core/core.go @@ -4,6 +4,7 @@ import ( "context" "embed" "errors" + "io/fs" "fmt" "reflect" "slices" @@ -26,7 +27,10 @@ var ( // core.WithAssets(assets), // ) func New(opts ...Option) (*Core, error) { + // Default IO rooted at "/" (full access, like os package) + defaultIO, _ := NewIO("/") c := &Core{ + io: defaultIO, etc: NewEtc(), crash: NewCrashHandler(), svc: newServiceManager(), @@ -123,7 +127,7 @@ func WithApp(app any) Option { } } -// WithAssets creates an Option that mounts the application's embedded assets. +// WithAssets creates an Option that mounts embedded assets. // The assets are accessible via c.Mnt(). func WithAssets(efs embed.FS) Option { return func(c *Core) error { @@ -136,10 +140,24 @@ func WithAssets(efs embed.FS) Option { } } -// WithMount creates an Option that mounts an embedded FS at a specific subdirectory. -func WithMount(efs embed.FS, basedir string) Option { +// WithIO creates an Option that sandboxes filesystem I/O to a root path. +// Default is "/" (full access). Use this to restrict c.Io() operations. +func WithIO(root string) Option { return func(c *Core) error { - sub, err := Mount(efs, basedir) + io, err := NewIO(root) + if err != nil { + return E("core.WithIO", "failed to create IO at "+root, err) + } + c.io = io + return nil + } +} + +// WithMount creates an Option that mounts an fs.FS at a specific subdirectory. +// The mounted assets are accessible via c.Mnt(). +func WithMount(fsys fs.FS, basedir string) Option { + return func(c *Core) error { + sub, err := Mount(fsys, basedir) if err != nil { return E("core.WithMount", "failed to mount "+basedir, err) } @@ -413,11 +431,3 @@ func (c *Core) Crypt() Crypt { // Core returns self, implementing the CoreProvider interface. func (c *Core) Core() *Core { return c } -// Assets returns the embedded filesystem containing the application's assets. -// Deprecated: use c.Mnt().Embed() instead. -func (c *Core) Assets() embed.FS { - if c.mnt != nil { - return c.mnt.Embed() - } - return embed.FS{} -} diff --git a/pkg/core/core_test.go b/pkg/core/core_test.go index d666ce9..3532101 100644 --- a/pkg/core/core_test.go +++ b/pkg/core/core_test.go @@ -187,8 +187,7 @@ var testFS embed.FS func TestCore_WithAssets_Good(t *testing.T) { c, err := New(WithAssets(testFS)) assert.NoError(t, err) - assets := c.Assets() - file, err := assets.Open("testdata/test.txt") + file, err := c.Mnt().Open("testdata/test.txt") assert.NoError(t, err) defer func() { _ = file.Close() }() content, err := io.ReadAll(file) diff --git a/pkg/core/interfaces.go b/pkg/core/interfaces.go index e3be378..8406b73 100644 --- a/pkg/core/interfaces.go +++ b/pkg/core/interfaces.go @@ -81,7 +81,8 @@ type LocaleProvider interface { // Core is the central application object that manages services, assets, and communication. type Core struct { App any // GUI runtime (e.g., Wails App) - set by WithApp option - mnt *Sub // Mount point for embedded assets + mnt *Sub // Mounted embedded assets (read-only) + io *IO // Local filesystem I/O (read/write, sandboxable) etc *Etc // Configuration, settings, and feature flags crash *CrashHandler // Panic recovery and crash reporting svc *serviceManager @@ -93,12 +94,22 @@ type Core struct { shutdown atomic.Bool } -// Mnt returns the mount point for embedded assets. -// Use this to read embedded files and extract template directories. +// Mnt returns the mounted embedded assets (read-only). +// +// c.Mnt().ReadString("persona/secops/developer.md") func (c *Core) Mnt() *Sub { return c.mnt } +// Io returns the local filesystem I/O layer. +// Default: rooted at "/". Sandboxable via WithIO("./data"). +// +// c.Io().Read("config.yaml") +// c.Io().Write("output.txt", content) +func (c *Core) Io() *IO { + return c.io +} + // Etc returns the configuration and feature flags store. // // c.Etc().Set("api_url", "https://api.lthn.sh") diff --git a/pkg/core/io.go b/pkg/core/io.go new file mode 100644 index 0000000..394385c --- /dev/null +++ b/pkg/core/io.go @@ -0,0 +1,307 @@ +// Package local provides a local filesystem implementation of the io.Medium interface. +package core + +import ( + "fmt" + goio "io" + "io/fs" + "os" + "os/user" + "path/filepath" + "strings" + "time" + + +) + +// Medium is a local filesystem storage backend. +type IO struct { + root string +} + +// New creates a new local Medium rooted at the given directory. +// Pass "/" for full filesystem access, or a specific path to sandbox. +func NewIO(root string) (*IO, error) { + abs, err := filepath.Abs(root) + if err != nil { + return nil, err + } + // Resolve symlinks so sandbox checks compare like-for-like. + // On macOS, /var is a symlink to /private/var — without this, + // EvalSymlinks on child paths resolves to /private/var/... while + // root stays /var/..., causing false sandbox escape detections. + if resolved, err := filepath.EvalSymlinks(abs); err == nil { + abs = resolved + } + return &IO{root: abs}, nil +} + +// path sanitises and returns the full path. +// Absolute paths are sandboxed under root (unless root is "/"). +func (m *IO) path(p string) string { + if p == "" { + return m.root + } + + // If the path is relative and the medium is rooted at "/", + // treat it as relative to the current working directory. + // This makes io.Local behave more like the standard 'os' package. + if m.root == "/" && !filepath.IsAbs(p) { + cwd, _ := os.Getwd() + return filepath.Join(cwd, p) + } + + // Use filepath.Clean with a leading slash to resolve all .. and . internally + // before joining with the root. This is a standard way to sandbox paths. + clean := filepath.Clean("/" + p) + + // If root is "/", allow absolute paths through + if m.root == "/" { + return clean + } + + // Join cleaned relative path with root + return filepath.Join(m.root, clean) +} + +// validatePath ensures the path is within the sandbox, following symlinks if they exist. +func (m *IO) validatePath(p string) (string, error) { + if m.root == "/" { + return m.path(p), nil + } + + // Split the cleaned path into components + parts := strings.Split(filepath.Clean("/"+p), string(os.PathSeparator)) + current := m.root + + for _, part := range parts { + if part == "" { + continue + } + + next := filepath.Join(current, part) + realNext, err := filepath.EvalSymlinks(next) + if err != nil { + if os.IsNotExist(err) { + // Part doesn't exist, we can't follow symlinks anymore. + // Since the path is already Cleaned and current is safe, + // appending a component to current will not escape. + current = next + continue + } + return "", err + } + + // Verify the resolved part is still within the root + rel, err := filepath.Rel(m.root, realNext) + if err != nil || strings.HasPrefix(rel, "..") { + // Security event: sandbox escape attempt + username := "unknown" + if u, err := user.Current(); err == nil { + username = u.Username + } + fmt.Fprintf(os.Stderr, "[%s] SECURITY sandbox escape detected root=%s path=%s attempted=%s user=%s\n", + time.Now().Format(time.RFC3339), m.root, p, realNext, username) + return "", os.ErrPermission // Path escapes sandbox + } + current = realNext + } + + return current, nil +} + +// Read returns file contents as string. +func (m *IO) Read(p string) (string, error) { + full, err := m.validatePath(p) + if err != nil { + return "", err + } + data, err := os.ReadFile(full) + if err != nil { + return "", err + } + return string(data), nil +} + +// Write saves content to file, creating parent directories as needed. +// Files are created with mode 0644. For sensitive files (keys, secrets), +// use WriteMode with 0600. +func (m *IO) Write(p, content string) error { + return m.WriteMode(p, content, 0644) +} + +// WriteMode saves content to file with explicit permissions. +// Use 0600 for sensitive files (encryption output, private keys, auth hashes). +func (m *IO) WriteMode(p, content string, mode os.FileMode) error { + full, err := m.validatePath(p) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(full), 0755); err != nil { + return err + } + return os.WriteFile(full, []byte(content), mode) +} + +// EnsureDir creates directory if it doesn't exist. +func (m *IO) EnsureDir(p string) error { + full, err := m.validatePath(p) + if err != nil { + return err + } + return os.MkdirAll(full, 0755) +} + +// IsDir returns true if path is a directory. +func (m *IO) IsDir(p string) bool { + if p == "" { + return false + } + full, err := m.validatePath(p) + if err != nil { + return false + } + info, err := os.Stat(full) + return err == nil && info.IsDir() +} + +// IsFile returns true if path is a regular file. +func (m *IO) IsFile(p string) bool { + if p == "" { + return false + } + full, err := m.validatePath(p) + if err != nil { + return false + } + info, err := os.Stat(full) + return err == nil && info.Mode().IsRegular() +} + +// Exists returns true if path exists. +func (m *IO) Exists(p string) bool { + full, err := m.validatePath(p) + if err != nil { + return false + } + _, err = os.Stat(full) + return err == nil +} + +// List returns directory entries. +func (m *IO) List(p string) ([]fs.DirEntry, error) { + full, err := m.validatePath(p) + if err != nil { + return nil, err + } + return os.ReadDir(full) +} + +// Stat returns file info. +func (m *IO) Stat(p string) (fs.FileInfo, error) { + full, err := m.validatePath(p) + if err != nil { + return nil, err + } + return os.Stat(full) +} + +// Open opens the named file for reading. +func (m *IO) Open(p string) (fs.File, error) { + full, err := m.validatePath(p) + if err != nil { + return nil, err + } + return os.Open(full) +} + +// Create creates or truncates the named file. +func (m *IO) Create(p string) (goio.WriteCloser, error) { + full, err := m.validatePath(p) + if err != nil { + return nil, err + } + if err := os.MkdirAll(filepath.Dir(full), 0755); err != nil { + return nil, err + } + return os.Create(full) +} + +// Append opens the named file for appending, creating it if it doesn't exist. +func (m *IO) Append(p string) (goio.WriteCloser, error) { + full, err := m.validatePath(p) + if err != nil { + return nil, err + } + if err := os.MkdirAll(filepath.Dir(full), 0755); err != nil { + return nil, err + } + return os.OpenFile(full, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) +} + +// ReadStream returns a reader for the file content. +// +// This is a convenience wrapper around Open that exposes a streaming-oriented +// API, as required by the io.Medium interface, while Open provides the more +// general filesystem-level operation. Both methods are kept for semantic +// clarity and backward compatibility. +func (m *IO) ReadStream(path string) (goio.ReadCloser, error) { + return m.Open(path) +} + +// WriteStream returns a writer for the file content. +// +// This is a convenience wrapper around Create that exposes a streaming-oriented +// API, as required by the io.Medium interface, while Create provides the more +// general filesystem-level operation. Both methods are kept for semantic +// clarity and backward compatibility. +func (m *IO) WriteStream(path string) (goio.WriteCloser, error) { + return m.Create(path) +} + +// Delete removes a file or empty directory. +func (m *IO) Delete(p string) error { + full, err := m.validatePath(p) + if err != nil { + return err + } + if full == "/" || full == os.Getenv("HOME") { + return E("local.Delete", "refusing to delete protected path: "+full, nil) + } + return os.Remove(full) +} + +// DeleteAll removes a file or directory recursively. +func (m *IO) DeleteAll(p string) error { + full, err := m.validatePath(p) + if err != nil { + return err + } + if full == "/" || full == os.Getenv("HOME") { + return E("local.DeleteAll", "refusing to delete protected path: "+full, nil) + } + return os.RemoveAll(full) +} + +// Rename moves a file or directory. +func (m *IO) Rename(oldPath, newPath string) error { + oldFull, err := m.validatePath(oldPath) + if err != nil { + return err + } + newFull, err := m.validatePath(newPath) + if err != nil { + return err + } + return os.Rename(oldFull, newFull) +} + +// FileGet is an alias for Read. +func (m *IO) FileGet(p string) (string, error) { + return m.Read(p) +} + +// FileSet is an alias for Write. +func (m *IO) FileSet(p, content string) error { + return m.Write(p, content) +} diff --git a/pkg/core/mnt.go b/pkg/core/mnt.go index 538dc54..3f60749 100644 --- a/pkg/core/mnt.go +++ b/pkg/core/mnt.go @@ -1,21 +1,17 @@ // SPDX-License-Identifier: EUPL-1.2 -// Mount operations for the Core framework. mount operations for the Core framework. +// Mount operations for the Core framework. // -// Mount operations attach data to/from binaries and watch live filesystems: -// -// - FS: mount an embed.FS subdirectory for scoped access -// - Extract: extract a template directory with variable substitution -// - Watch: observe filesystem changes (file watcher) -// -// Zero external dependencies. All operations use stdlib only. +// Sub provides scoped filesystem access that works with: +// - go:embed (embed.FS) +// - any fs.FS implementation +// - the Core asset registry (AddAsset/GetAsset from embed.go) // // Usage: // -// sub, _ := mnt.FS(myEmbed, "lib/persona") -// content, _ := sub.ReadFile("secops/developer.md") -// -// mnt.Extract(sub, "/tmp/workspace", map[string]string{"Name": "myproject"}) +// sub, _ := core.Mount(myFS, "lib/persona") +// content, _ := sub.ReadString("secops/developer.md") +// sub.Extract("/tmp/workspace", data) package core import ( @@ -24,17 +20,24 @@ import ( "path/filepath" ) -// Sub wraps an embed.FS with a basedir for scoped access. +// Sub wraps an fs.FS with a basedir for scoped access. // All paths are relative to basedir. type Sub struct { basedir string - fs embed.FS + fsys fs.FS + embedFS *embed.FS // kept for Embed() backwards compat } -// FS creates a scoped view of an embed.FS anchored at basedir. -// Returns error if basedir doesn't exist in the embedded filesystem. -func Mount(efs embed.FS, basedir string) (*Sub, error) { - s := &Sub{fs: efs, basedir: basedir} +// Mount creates a scoped view of an fs.FS anchored at basedir. +// Works with embed.FS, os.DirFS, or any fs.FS implementation. +func Mount(fsys fs.FS, basedir string) (*Sub, error) { + s := &Sub{fsys: fsys, basedir: basedir} + + // If it's an embed.FS, keep a reference for Embed() + if efs, ok := fsys.(embed.FS); ok { + s.embedFS = &efs + } + // Verify the basedir exists if _, err := s.ReadDir("."); err != nil { return nil, err @@ -42,23 +45,29 @@ func Mount(efs embed.FS, basedir string) (*Sub, error) { return s, nil } +// MountEmbed creates a scoped view of an embed.FS. +// Convenience wrapper that preserves the embed.FS type for Embed(). +func MountEmbed(efs embed.FS, basedir string) (*Sub, error) { + return Mount(efs, basedir) +} + func (s *Sub) path(name string) string { return filepath.ToSlash(filepath.Join(s.basedir, name)) } // Open opens the named file for reading. func (s *Sub) Open(name string) (fs.File, error) { - return s.fs.Open(s.path(name)) + return s.fsys.Open(s.path(name)) } // ReadDir reads the named directory. func (s *Sub) ReadDir(name string) ([]fs.DirEntry, error) { - return s.fs.ReadDir(s.path(name)) + return fs.ReadDir(s.fsys, s.path(name)) } // ReadFile reads the named file. func (s *Sub) ReadFile(name string) ([]byte, error) { - return s.fs.ReadFile(s.path(name)) + return fs.ReadFile(s.fsys, s.path(name)) } // ReadString reads the named file as a string. @@ -72,12 +81,25 @@ func (s *Sub) ReadString(name string) (string, error) { // Sub returns a new Sub anchored at a subdirectory within this Sub. func (s *Sub) Sub(subDir string) (*Sub, error) { - return Mount(s.fs, s.path(subDir)) + sub, err := fs.Sub(s.fsys, s.path(subDir)) + if err != nil { + return nil, err + } + return &Sub{fsys: sub, basedir: "."}, nil } -// Embed returns the underlying embed.FS. +// FS returns the underlying fs.FS. +func (s *Sub) FS() fs.FS { + return s.fsys +} + +// Embed returns the underlying embed.FS if mounted from one. +// Returns zero embed.FS if mounted from a non-embed source. func (s *Sub) Embed() embed.FS { - return s.fs + if s.embedFS != nil { + return *s.embedFS + } + return embed.FS{} } // BaseDir returns the basedir this Sub is anchored at.