feat: add core.Io() — local filesystem I/O on Core struct
Brings go-io/local into Core as c.Io():
c.Io().Read("config.yaml")
c.Io().Write("output.txt", content)
c.Io().WriteMode("key.pem", data, 0600)
c.Io().IsFile("go.mod")
c.Io().List(".")
c.Io().Delete("temp.txt")
Default: rooted at "/" (full access like os package).
Sandbox: core.WithIO("./data") restricts all operations.
c.Mnt() stays for embedded/mounted assets (read-only).
c.Io() is for local filesystem (read/write/delete).
WithMount stays for mounting fs.FS subdirectories.
WithIO added for sandboxing local I/O.
Based on go-io/local/client.go (~300 lines), zero external deps.
Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
parent
077fde9516
commit
d7f9447e7a
5 changed files with 390 additions and 41 deletions
|
|
@ -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{}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
307
pkg/core/io.go
Normal file
307
pkg/core/io.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue