This repository has been archived on 2026-08-16. You can view files and clone it, but cannot push or open issues or pull requests.
ollie-9p/fs/tree.go

370 lines
9.5 KiB
Go

package fs
import (
"context"
"fmt"
"os"
"path/filepath"
)
// Tree is a configurable filesystem-backed FileTree.
// It supports single or multiple directories (union), read-only mode,
// a synthetic index entry, subdirectory operations, custom path resolution,
// and mounting child DirStores at names (sub-stores).
type Tree struct {
Tree []string
perm os.FileMode
readOnly bool
indexFn func(FileTree) ([]byte, error)
resolveFn func([]string, string) (string, error)
listFn func([]string) ([]os.DirEntry, error)
createFn func([]string, string, os.FileMode) error
deleteFn func([]string, string) error
renameFn func([]string, string, string) error
openFn func([]string, string) (File, error)
statFn func([]string, string) (os.FileInfo, error)
readdirFn func([]string, string) ([]os.DirEntry, error)
children map[string]*Tree
Data any // user-attached state
}
// Option configures a Tree.
type Option func(*Tree)
// WithReadOnly makes the tree reject writes, creates, deletes, and renames.
func WithReadOnly() Option { return func(d *Tree) { d.readOnly = true } }
// WithIndex adds a synthetic read-only "idx" entry whose content is
// generated by calling fn with the tree itself.
func WithIndex(fn func(FileTree) ([]byte, error)) Option {
return func(d *Tree) { d.indexFn = fn }
}
// WithResolver overrides how virtual names map to filesystem paths.
func WithResolver(fn func(dirs []string, name string) (string, error)) Option {
return func(d *Tree) { d.resolveFn = fn }
}
// WithLister overrides how the tree lists its entries.
func WithLister(fn func(dirs []string) ([]os.DirEntry, error)) Option {
return func(d *Tree) { d.listFn = fn }
}
// WithCreator overrides file creation logic.
func WithCreator(fn func(dirs []string, name string, perm os.FileMode) error) Option {
return func(d *Tree) { d.createFn = fn }
}
// WithDeleter overrides file deletion logic.
func WithDeleter(fn func(dirs []string, name string) error) Option {
return func(d *Tree) { d.deleteFn = fn }
}
// WithRenamer overrides rename logic.
func WithRenamer(fn func(dirs []string, old, new string) error) Option {
return func(d *Tree) { d.renameFn = fn }
}
// WithOpener overrides how entries are opened (bypasses filesystem read).
func WithOpener(fn func(dirs []string, name string) (File, error)) Option {
return func(d *Tree) { d.openFn = fn }
}
// WithStat overrides how entries are stat'd.
func WithStat(fn func(dirs []string, name string) (os.FileInfo, error)) Option {
return func(d *Tree) { d.statFn = fn }
}
// WithReaddir overrides subdirectory listing.
func WithReaddir(fn func(dirs []string, name string) ([]os.DirEntry, error)) Option {
return func(d *Tree) { d.readdirFn = fn }
}
// NewTree creates a Tree backed by one or more directories.
// The first directory is the write target for unions.
func NewTree(dirs []string, perm os.FileMode, opts ...Option) *Tree {
d := &Tree{Tree: dirs, perm: perm}
for _, o := range opts {
o(d)
}
return d
}
// Mount attaches a child Tree at the given name.
func (d *Tree) Mount(name string, child *Tree) {
if d.children == nil {
d.children = make(map[string]*Tree)
}
d.children[name] = child
}
// Unmount removes a mounted child.
func (d *Tree) Unmount(name string) {
delete(d.children, name)
}
// Child returns the child mounted at name, or nil.
func (d *Tree) Child(name string) *Tree {
if d.children == nil {
return nil
}
return d.children[name]
}
// split checks if name routes to a child tree. Returns (child, rest, true)
// if a child handles this path, or (nil, "", false) otherwise.
func (d *Tree) split(name string) (*Tree, string, bool) {
if d.children == nil {
return nil, "", false
}
// Check for exact child match (directory itself).
if child, ok := d.children[name]; ok {
return child, "", true
}
// Check for child prefix: "childname/rest"
if i := indexOf(name, '/'); i >= 0 {
prefix := name[:i]
if child, ok := d.children[prefix]; ok {
return child, name[i+1:], true
}
}
return nil, "", false
}
func indexOf(s string, b byte) int {
for i := range len(s) {
if s[i] == b {
return i
}
}
return -1
}
func (d *Tree) resolve(name string) (string, error) {
if d.resolveFn != nil {
return d.resolveFn(d.Tree, name)
}
for _, dir := range d.Tree {
p := filepath.Join(dir, name)
if _, err := os.Stat(p); err == nil {
return p, nil
}
}
return "", fmt.Errorf("%s: not found", name)
}
func (d *Tree) writePath(name string) string {
return filepath.Join(d.Tree[0], name)
}
func (d *Tree) Stat(name string) (os.FileInfo, error) {
// Check children first.
if child, rest, ok := d.split(name); ok {
if rest == "" {
return &SyntheticFileInfo{Name_: name, Mode_: PermChildDir, IsDir_: true}, nil
}
return child.Stat(rest)
}
if name == "idx" && d.indexFn != nil {
return &SyntheticFileInfo{Name_: "idx", Mode_: PermIdx}, nil
}
// Custom stat hook.
if d.statFn != nil {
return d.statFn(d.Tree, name)
}
// Synthetic files: use opener for stat.
if d.openFn != nil {
e, err := d.openFn(d.Tree, name)
if err != nil {
return nil, err
}
return e.Stat()
}
p, err := d.resolve(name)
if err != nil {
return nil, err
}
info, err := os.Stat(p)
if err != nil {
return nil, err
}
if d.resolveFn != nil {
return &SyntheticFileInfo{Name_: name, Mode_: info.Mode(), Size_: info.Size()}, nil
}
return info, nil
}
func (d *Tree) List() ([]os.DirEntry, error) {
if d.listFn != nil {
entries, err := d.listFn(d.Tree)
if err != nil {
return nil, err
}
if d.indexFn != nil {
result := make([]os.DirEntry, 0, len(entries)+1)
result = append(result, FileEntry("idx", PermIdx))
result = append(result, entries...)
entries = result
}
for name := range d.children {
entries = append(entries, DirEntry(name, PermChildDir))
}
return entries, nil
}
seen := make(map[string]bool)
var result []os.DirEntry
if d.indexFn != nil {
result = append(result, FileEntry("idx", PermIdx))
seen["idx"] = true
}
for _, dir := range d.Tree {
entries, err := os.ReadDir(dir)
if err != nil {
continue
}
for _, e := range entries {
if !seen[e.Name()] {
seen[e.Name()] = true
result = append(result, e)
}
}
}
for name := range d.children {
if !seen[name] {
result = append(result, DirEntry(name, PermChildDir))
}
}
return result, nil
}
func (d *Tree) Open(name string) (File, error) {
// Delegate to child stores.
if child, rest, ok := d.split(name); ok {
if rest == "" {
return nil, fmt.Errorf("%s: is a directory", name)
}
return child.Open(rest)
}
notBlocking := func(context.Context, string) ([]byte, string, error) {
return nil, "", fmt.Errorf("blocking read not supported")
}
if name == "idx" && d.indexFn != nil {
return &FileConfig{
StatFn: func() (os.FileInfo, error) { return &SyntheticFileInfo{Name_: "idx", Mode_: PermIdx}, nil },
ReadFn: func() ([]byte, error) { return d.indexFn(d) },
WriteFn: func([]byte) error { return fmt.Errorf("idx: read-only") },
BlockingReadFn: notBlocking,
}, nil
}
if d.openFn != nil {
return d.openFn(d.Tree, name)
}
p, err := d.resolve(name)
if err != nil {
if d.readOnly {
return nil, err
}
// Not found anywhere; write path is in first dir.
p = d.writePath(name)
}
wp := d.writePath(name)
writeFn := func(data []byte) error {
if d.readOnly {
return fmt.Errorf("%s: read-only", name)
}
if err := os.MkdirAll(filepath.Dir(wp), PermMkdir); err != nil {
return err
}
return os.WriteFile(wp, data, d.perm)
}
return &FileConfig{
StatFn: func() (os.FileInfo, error) { return os.Stat(p) },
ReadFn: func() ([]byte, error) { return os.ReadFile(p) },
WriteFn: writeFn,
BlockingReadFn: notBlocking,
}, nil
}
func (d *Tree) Create(name string) error {
if child, rest, ok := d.split(name); ok && rest != "" {
return child.Create(rest)
}
if d.readOnly {
return fmt.Errorf("read-only tree")
}
if d.createFn != nil {
return d.createFn(d.Tree, name, d.perm)
}
p := d.writePath(name)
if err := os.MkdirAll(filepath.Dir(p), PermMkdir); err != nil {
return err
}
return os.WriteFile(p, nil, d.perm)
}
func (d *Tree) Delete(name string) error {
if child, rest, ok := d.split(name); ok && rest != "" {
return child.Delete(rest)
}
if d.readOnly {
return fmt.Errorf("read-only tree")
}
if d.deleteFn != nil {
return d.deleteFn(d.Tree, name)
}
p, err := d.resolve(name)
if err != nil {
return err
}
return os.Remove(p)
}
func (d *Tree) Rename(old, new string) error {
if child, rest, ok := d.split(old); ok && rest != "" {
return child.Rename(rest, new)
}
if d.readOnly {
return fmt.Errorf("read-only tree")
}
if d.renameFn != nil {
return d.renameFn(d.Tree, old, new)
}
p, err := d.resolve(old)
if err != nil {
return err
}
return os.Rename(p, filepath.Join(filepath.Dir(p), new))
}
// Readdir lists entries in a subdirectory.
func (d *Tree) Readdir(rel string) ([]os.DirEntry, error) {
// Check if rel routes to a child.
if child, rest, ok := d.split(rel); ok {
if rest == "" {
return child.List()
}
return child.Readdir(rest)
}
if d.readdirFn != nil {
return d.readdirFn(d.Tree, rel)
}
if len(d.Tree) == 0 {
return nil, fmt.Errorf("%s: not found", rel)
}
return os.ReadDir(filepath.Join(d.Tree[0], rel))
}
// MkdirAll creates a directory relative to the first backing dir.
func (d *Tree) MkdirAll(rel string) error {
if child, rest, ok := d.split(rel); ok && rest != "" {
return child.MkdirAll(rest)
}
if d.readOnly {
return fmt.Errorf("read-only tree")
}
if len(d.Tree) == 0 {
return fmt.Errorf("no backing directory")
}
return os.MkdirAll(filepath.Join(d.Tree[0], rel), PermMkdir)
}