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:
Snider 2026-03-18 00:42:41 +00:00
parent 077fde9516
commit d7f9447e7a
5 changed files with 390 additions and 41 deletions

View file

@ -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{}
}

View file

@ -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)

View 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
View 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)
}

View file

@ -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.