go-io/local/medium.go
Virgil c95697e4f5
Some checks are pending
CI / test (push) Waiting to run
CI / auto-fix (push) Waiting to run
CI / auto-merge (push) Waiting to run
Sort local listings deterministically
2026-04-03 06:55:51 +00:00

482 lines
13 KiB
Go

// Example: medium, _ := local.New("/srv/app")
// Example: _ = medium.Write("config/app.yaml", "port: 8080")
// Example: content, _ := medium.Read("config/app.yaml")
package local
import (
"cmp"
goio "io"
"io/fs"
"slices"
"syscall"
core "dappco.re/go/core"
)
// Example: medium, _ := local.New("/srv/app")
// Example: _ = medium.Write("config/app.yaml", "port: 8080")
type Medium struct {
filesystemRoot string
}
var unrestrictedFileSystem = (&core.Fs{}).NewUnrestricted()
// Example: medium, _ := local.New("/srv/app")
// Example: _ = medium.Write("config/app.yaml", "port: 8080")
func New(root string) (*Medium, error) {
absoluteRoot := absolutePath(root)
if resolvedRoot, err := resolveSymlinksPath(absoluteRoot); err == nil {
absoluteRoot = resolvedRoot
}
return &Medium{filesystemRoot: absoluteRoot}, nil
}
func dirSeparator() string {
if separator := core.Env("CORE_PATH_SEPARATOR"); separator != "" {
return separator
}
if separator := core.Env("DS"); separator != "" {
return separator
}
return "/"
}
func normalisePath(path string) string {
separator := dirSeparator()
if separator == "/" {
return core.Replace(path, "\\", separator)
}
return core.Replace(path, "/", separator)
}
func currentWorkingDir() string {
if workingDirectory := core.Env("CORE_WORKING_DIRECTORY"); workingDirectory != "" {
return workingDirectory
}
if workingDirectory := core.Env("DIR_CWD"); workingDirectory != "" {
return workingDirectory
}
return "."
}
func absolutePath(path string) string {
path = normalisePath(path)
if core.PathIsAbs(path) {
return core.Path(path)
}
return core.Path(currentWorkingDir(), path)
}
func cleanSandboxPath(path string) string {
return core.Path(dirSeparator() + normalisePath(path))
}
func splitPathParts(path string) []string {
trimmed := core.TrimPrefix(path, dirSeparator())
if trimmed == "" {
return nil
}
var parts []string
for _, part := range core.Split(trimmed, dirSeparator()) {
if part == "" {
continue
}
parts = append(parts, part)
}
return parts
}
func resolveSymlinksPath(path string) (string, error) {
return resolveSymlinksRecursive(absolutePath(path), map[string]struct{}{})
}
func resolveSymlinksRecursive(path string, seen map[string]struct{}) (string, error) {
path = core.Path(path)
if path == dirSeparator() {
return path, nil
}
current := dirSeparator()
for _, part := range splitPathParts(path) {
next := core.Path(current, part)
info, err := lstat(next)
if err != nil {
if core.Is(err, syscall.ENOENT) {
current = next
continue
}
return "", err
}
if !isSymlink(info.Mode) {
current = next
continue
}
target, err := readlink(next)
if err != nil {
return "", err
}
target = normalisePath(target)
if !core.PathIsAbs(target) {
target = core.Path(current, target)
} else {
target = core.Path(target)
}
if _, ok := seen[target]; ok {
return "", core.E("local.resolveSymlinksPath", core.Concat("symlink cycle: ", target), fs.ErrInvalid)
}
seen[target] = struct{}{}
resolved, err := resolveSymlinksRecursive(target, seen)
delete(seen, target)
if err != nil {
return "", err
}
current = resolved
}
return current, nil
}
func isWithinRoot(root, target string) bool {
root = core.Path(root)
target = core.Path(target)
if root == dirSeparator() {
return true
}
return target == root || core.HasPrefix(target, root+dirSeparator())
}
func canonicalPath(path string) string {
if path == "" {
return ""
}
if resolved, err := resolveSymlinksPath(path); err == nil {
return resolved
}
return absolutePath(path)
}
func isProtectedPath(fullPath string) bool {
fullPath = canonicalPath(fullPath)
protected := map[string]struct{}{
canonicalPath(dirSeparator()): {},
}
for _, home := range []string{core.Env("HOME"), core.Env("DIR_HOME")} {
if home == "" {
continue
}
protected[canonicalPath(home)] = struct{}{}
}
_, ok := protected[fullPath]
return ok
}
func logSandboxEscape(root, path, attempted string) {
username := core.Env("USER")
if username == "" {
username = "unknown"
}
core.Security("sandbox escape detected", "root", root, "path", path, "attempted", attempted, "user", username)
}
func (medium *Medium) sandboxedPath(path string) string {
if path == "" {
return medium.filesystemRoot
}
if medium.filesystemRoot == dirSeparator() && !core.PathIsAbs(normalisePath(path)) {
return core.Path(currentWorkingDir(), normalisePath(path))
}
clean := cleanSandboxPath(path)
if medium.filesystemRoot == dirSeparator() {
return clean
}
return core.Path(medium.filesystemRoot, core.TrimPrefix(clean, dirSeparator()))
}
func (medium *Medium) validatePath(path string) (string, error) {
if medium.filesystemRoot == dirSeparator() {
return medium.sandboxedPath(path), nil
}
parts := splitPathParts(cleanSandboxPath(path))
current := medium.filesystemRoot
for _, part := range parts {
next := core.Path(current, part)
realNext, err := resolveSymlinksPath(next)
if err != nil {
if core.Is(err, syscall.ENOENT) {
current = next
continue
}
return "", err
}
if !isWithinRoot(medium.filesystemRoot, realNext) {
logSandboxEscape(medium.filesystemRoot, path, realNext)
return "", fs.ErrPermission
}
current = realNext
}
return current, nil
}
func (medium *Medium) Read(path string) (string, error) {
resolvedPath, err := medium.validatePath(path)
if err != nil {
return "", err
}
return resultString("local.Read", core.Concat("read failed: ", path), unrestrictedFileSystem.Read(resolvedPath))
}
func (medium *Medium) Write(path, content string) error {
return medium.WriteMode(path, content, 0644)
}
func (medium *Medium) WriteMode(path, content string, mode fs.FileMode) error {
resolvedPath, err := medium.validatePath(path)
if err != nil {
return err
}
return resultError("local.WriteMode", core.Concat("write failed: ", path), unrestrictedFileSystem.WriteMode(resolvedPath, content, mode))
}
// Example: _ = medium.EnsureDir("config/app")
func (medium *Medium) EnsureDir(path string) error {
resolvedPath, err := medium.validatePath(path)
if err != nil {
return err
}
return resultError("local.EnsureDir", core.Concat("ensure dir failed: ", path), unrestrictedFileSystem.EnsureDir(resolvedPath))
}
// Example: isDirectory := medium.IsDir("config")
func (medium *Medium) IsDir(path string) bool {
if path == "" {
return false
}
resolvedPath, err := medium.validatePath(path)
if err != nil {
return false
}
return unrestrictedFileSystem.IsDir(resolvedPath)
}
// Example: isFile := medium.IsFile("config/app.yaml")
func (medium *Medium) IsFile(path string) bool {
if path == "" {
return false
}
resolvedPath, err := medium.validatePath(path)
if err != nil {
return false
}
return unrestrictedFileSystem.IsFile(resolvedPath)
}
// Example: exists := medium.Exists("config/app.yaml")
func (medium *Medium) Exists(path string) bool {
resolvedPath, err := medium.validatePath(path)
if err != nil {
return false
}
return unrestrictedFileSystem.Exists(resolvedPath)
}
// Example: entries, _ := medium.List("config")
func (medium *Medium) List(path string) ([]fs.DirEntry, error) {
resolvedPath, err := medium.validatePath(path)
if err != nil {
return nil, err
}
entries, err := resultDirEntries("local.List", core.Concat("list failed: ", path), unrestrictedFileSystem.List(resolvedPath))
if err != nil {
return nil, err
}
slices.SortFunc(entries, func(a, b fs.DirEntry) int {
return cmp.Compare(a.Name(), b.Name())
})
return entries, nil
}
// Example: info, _ := medium.Stat("config/app.yaml")
func (medium *Medium) Stat(path string) (fs.FileInfo, error) {
resolvedPath, err := medium.validatePath(path)
if err != nil {
return nil, err
}
return resultFileInfo("local.Stat", core.Concat("stat failed: ", path), unrestrictedFileSystem.Stat(resolvedPath))
}
// Example: file, _ := medium.Open("config/app.yaml")
func (medium *Medium) Open(path string) (fs.File, error) {
resolvedPath, err := medium.validatePath(path)
if err != nil {
return nil, err
}
return resultFile("local.Open", core.Concat("open failed: ", path), unrestrictedFileSystem.Open(resolvedPath))
}
// Example: writer, _ := medium.Create("logs/app.log")
func (medium *Medium) Create(path string) (goio.WriteCloser, error) {
resolvedPath, err := medium.validatePath(path)
if err != nil {
return nil, err
}
return resultWriteCloser("local.Create", core.Concat("create failed: ", path), unrestrictedFileSystem.Create(resolvedPath))
}
// Example: writer, _ := medium.Append("logs/app.log")
func (medium *Medium) Append(path string) (goio.WriteCloser, error) {
resolvedPath, err := medium.validatePath(path)
if err != nil {
return nil, err
}
return resultWriteCloser("local.Append", core.Concat("append failed: ", path), unrestrictedFileSystem.Append(resolvedPath))
}
// Example: reader, _ := medium.ReadStream("logs/app.log")
func (medium *Medium) ReadStream(path string) (goio.ReadCloser, error) {
return medium.Open(path)
}
// Example: writer, _ := medium.WriteStream("logs/app.log")
func (medium *Medium) WriteStream(path string) (goio.WriteCloser, error) {
return medium.Create(path)
}
// Example: _ = medium.Delete("config/app.yaml")
func (medium *Medium) Delete(path string) error {
resolvedPath, err := medium.validatePath(path)
if err != nil {
return err
}
if isProtectedPath(resolvedPath) {
return core.E("local.Delete", core.Concat("refusing to delete protected path: ", resolvedPath), nil)
}
return resultError("local.Delete", core.Concat("delete failed: ", path), unrestrictedFileSystem.Delete(resolvedPath))
}
// Example: _ = medium.DeleteAll("logs/archive")
func (medium *Medium) DeleteAll(path string) error {
resolvedPath, err := medium.validatePath(path)
if err != nil {
return err
}
if isProtectedPath(resolvedPath) {
return core.E("local.DeleteAll", core.Concat("refusing to delete protected path: ", resolvedPath), nil)
}
return resultError("local.DeleteAll", core.Concat("delete all failed: ", path), unrestrictedFileSystem.DeleteAll(resolvedPath))
}
// Example: _ = medium.Rename("drafts/todo.txt", "archive/todo.txt")
func (medium *Medium) Rename(oldPath, newPath string) error {
oldResolvedPath, err := medium.validatePath(oldPath)
if err != nil {
return err
}
newResolvedPath, err := medium.validatePath(newPath)
if err != nil {
return err
}
return resultError("local.Rename", core.Concat("rename failed: ", oldPath), unrestrictedFileSystem.Rename(oldResolvedPath, newResolvedPath))
}
func lstat(path string) (*syscall.Stat_t, error) {
info := &syscall.Stat_t{}
if err := syscall.Lstat(path, info); err != nil {
return nil, err
}
return info, nil
}
func isSymlink(mode uint32) bool {
return mode&syscall.S_IFMT == syscall.S_IFLNK
}
func readlink(path string) (string, error) {
size := 256
for {
linkBuffer := make([]byte, size)
bytesRead, err := syscall.Readlink(path, linkBuffer)
if err != nil {
return "", err
}
if bytesRead < len(linkBuffer) {
return string(linkBuffer[:bytesRead]), nil
}
size *= 2
}
}
func resultError(operation, message string, result core.Result) error {
if result.OK {
return nil
}
if err, ok := result.Value.(error); ok {
return core.E(operation, message, err)
}
return core.E(operation, message, nil)
}
func resultString(operation, message string, result core.Result) (string, error) {
if !result.OK {
return "", resultError(operation, message, result)
}
value, ok := result.Value.(string)
if !ok {
return "", core.E(operation, "unexpected result type", nil)
}
return value, nil
}
func resultDirEntries(operation, message string, result core.Result) ([]fs.DirEntry, error) {
if !result.OK {
return nil, resultError(operation, message, result)
}
entries, ok := result.Value.([]fs.DirEntry)
if !ok {
return nil, core.E(operation, "unexpected result type", nil)
}
return entries, nil
}
func resultFileInfo(operation, message string, result core.Result) (fs.FileInfo, error) {
if !result.OK {
return nil, resultError(operation, message, result)
}
fileInfo, ok := result.Value.(fs.FileInfo)
if !ok {
return nil, core.E(operation, "unexpected result type", nil)
}
return fileInfo, nil
}
func resultFile(operation, message string, result core.Result) (fs.File, error) {
if !result.OK {
return nil, resultError(operation, message, result)
}
file, ok := result.Value.(fs.File)
if !ok {
return nil, core.E(operation, "unexpected result type", nil)
}
return file, nil
}
func resultWriteCloser(operation, message string, result core.Result) (goio.WriteCloser, error) {
if !result.OK {
return nil, resultError(operation, message, result)
}
writer, ok := result.Value.(goio.WriteCloser)
if !ok {
return nil, core.E(operation, "unexpected result type", nil)
}
return writer, nil
}