431 lines
12 KiB
Go
431 lines
12 KiB
Go
package fsedsl
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
)
|
|
|
|
// BuildTree walks an FsNodeDecl spec and produces a *Tree.
|
|
// It validates the spec, resolves inheritance, and wires handlers.
|
|
func BuildTree[Ctx any](spec FsNodeDecl[Ctx], rootCtx Ctx) *Tree {
|
|
validate(&spec, "")
|
|
tree := build(&spec, "", "", rootCtx)
|
|
return tree
|
|
}
|
|
|
|
// ── validation ───────────────────────────────────────────────────
|
|
|
|
func validate[Ctx any](d *FsNodeDecl[Ctx], path string) {
|
|
hasChildren := len(d.Children) > 0
|
|
hasBindings := d.Bindings != nil
|
|
hasFileHandlers := d.Read != nil || d.Write != nil || d.BlockOnce != nil || d.Stream != nil || d.Request != nil
|
|
hasCreate := d.Create != nil
|
|
hasRemove := d.Remove != nil
|
|
|
|
// A node is a directory if it has children.
|
|
// Bindings alone (Each) can be either: dir template (has children) or file template (has handlers).
|
|
isDir := hasChildren
|
|
|
|
name := d.Name
|
|
fullPath := path + "/" + name
|
|
if path == "" {
|
|
fullPath = name
|
|
}
|
|
|
|
// A directory node (has children) cannot have file handlers.
|
|
// But an Each without children CAN have file handlers — it's a file template.
|
|
if isDir && hasFileHandlers {
|
|
panic(fmt.Sprintf("BuildTree: %q: directory node cannot have file handlers", fullPath))
|
|
}
|
|
|
|
// At most one blocking variant.
|
|
var blocking int
|
|
if d.BlockOnce != nil {
|
|
blocking++
|
|
}
|
|
if d.Stream != nil {
|
|
blocking++
|
|
}
|
|
if d.Request != nil {
|
|
blocking++
|
|
}
|
|
if blocking > 1 {
|
|
panic(fmt.Sprintf("BuildTree: %q: at most one of BlockOnce, Stream, Request may be set", fullPath))
|
|
}
|
|
|
|
// Create only valid on directory nodes.
|
|
if hasCreate && !isDir {
|
|
panic(fmt.Sprintf("BuildTree: %q: Create on non-directory node", fullPath))
|
|
}
|
|
// Remove is valid on directories and file templates (Bindings with file handlers).
|
|
isFileTemplate := hasBindings && !isDir && hasFileHandlers
|
|
if hasRemove && !isDir && !isFileTemplate {
|
|
panic(fmt.Sprintf("BuildTree: %q: Remove on non-directory/non-file-template node", fullPath))
|
|
}
|
|
|
|
// Template nodes must have Bindings set.
|
|
if strings.HasPrefix(name, "{") && strings.HasSuffix(name, "}") && !hasBindings {
|
|
panic(fmt.Sprintf("BuildTree: %q: template node %q requires Bindings", fullPath, name))
|
|
}
|
|
|
|
// Validate children recursively.
|
|
for i := range d.Children {
|
|
validate(&d.Children[i], fullPath)
|
|
}
|
|
}
|
|
|
|
// build ────────────────────────────────────────────────────────
|
|
|
|
func build[Ctx any](d *FsNodeDecl[Ctx], parentUid, parentGid string, parentCtx Ctx) *Tree {
|
|
// Resolve inheritance.
|
|
uid := d.UID
|
|
if uid == "" {
|
|
uid = parentUid
|
|
}
|
|
gid := d.GID
|
|
if gid == "" {
|
|
gid = parentGid
|
|
}
|
|
mode := d.Mode
|
|
|
|
hasChildren := len(d.Children) > 0
|
|
isDir := hasChildren
|
|
|
|
if isDir {
|
|
// Directory node.
|
|
if mode == 0 {
|
|
mode = 0755
|
|
}
|
|
|
|
t := NewTree(nil, mode,
|
|
WithLister(func(_ []string) ([]os.DirEntry, error) {
|
|
return listDir(d, uid, gid, parentCtx)
|
|
}),
|
|
WithReaddir(func(_ []string, name string) ([]os.DirEntry, error) {
|
|
return readdirDir(d, uid, gid, name, parentCtx)
|
|
}),
|
|
WithStat(func(_ []string, name string) (os.FileInfo, error) {
|
|
return statDir(d, uid, gid, name, parentCtx)
|
|
}),
|
|
WithOpener(func(_ []string, name string) (File, error) {
|
|
return openDir(d, uid, gid, name, parentCtx)
|
|
}),
|
|
WithDeleter(func(_ []string, name string) error {
|
|
return deleteDir(d, name, parentCtx)
|
|
}),
|
|
WithCreator(func(_ []string, name string, _ os.FileMode) error {
|
|
if d.Create != nil {
|
|
return d.Create(parentCtx, name)
|
|
}
|
|
return fmt.Errorf("create not supported")
|
|
}),
|
|
WithRenamer(func(_ []string, old, new string) error {
|
|
return renameDir(d, old, new, parentCtx)
|
|
}),
|
|
)
|
|
return t
|
|
}
|
|
|
|
// Leaf node.
|
|
if mode == 0 {
|
|
mode = 0444
|
|
}
|
|
return NewTree(nil, 0,
|
|
WithLister(func(_ []string) ([]os.DirEntry, error) {
|
|
return nil, fmt.Errorf("%s: is a file", d.Name)
|
|
}),
|
|
WithOpener(func(_ []string, name string) (File, error) {
|
|
return openLeaf(d, uid, gid, mode, parentCtx, name)
|
|
}),
|
|
)
|
|
}
|
|
|
|
// ── directory ops ────────────────────────────────────────────────
|
|
|
|
func listDir[Ctx any](d *FsNodeDecl[Ctx], uid, gid string, ctx Ctx) ([]os.DirEntry, error) {
|
|
// Static directories: return static children and also resolve
|
|
// template/blueprint children for dynamic entries.
|
|
var entries []os.DirEntry
|
|
for _, c := range d.Children {
|
|
if isTemplateName(c.Name) {
|
|
// Blueprint nodes: get bindings and list their names.
|
|
if c.Bindings != nil {
|
|
bindings, err := c.Bindings(ctx)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
// An Each with file handlers (no children) creates files, not directories.
|
|
hasFileHandlers := c.Read != nil || c.Write != nil || c.BlockOnce != nil || c.Stream != nil || c.Request != nil
|
|
isFileTemplate := len(c.Children) == 0 && hasFileHandlers
|
|
for _, b := range bindings {
|
|
m := c.Mode
|
|
if m == 0 {
|
|
m = 0755
|
|
}
|
|
if isFileTemplate {
|
|
entries = append(entries, FileEntry(b.Name, m))
|
|
} else {
|
|
entries = append(entries, DirEntry(b.Name, m))
|
|
}
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
m := c.Mode
|
|
if m == 0 {
|
|
m = 0755
|
|
}
|
|
// An Each with file handlers (no children) creates files, not directories.
|
|
hasFileHandlers := c.Read != nil || c.Write != nil || c.BlockOnce != nil || c.Stream != nil || c.Request != nil
|
|
isDir := len(c.Children) > 0 || (c.Bindings != nil && !hasFileHandlers)
|
|
if isDir {
|
|
entries = append(entries, DirEntry(c.Name, m))
|
|
} else {
|
|
entries = append(entries, FileEntry(c.Name, m))
|
|
}
|
|
}
|
|
return entries, nil
|
|
}
|
|
|
|
func readdirDir[Ctx any](d *FsNodeDecl[Ctx], uid, gid, name string, ctx Ctx) ([]os.DirEntry, error) {
|
|
child, childCtx := findChild(d, name, ctx)
|
|
if child == nil {
|
|
return nil, fmt.Errorf("%s: not found", name)
|
|
}
|
|
return listDir(child, resolveUID(child, uid), resolveGID(child, gid), childCtx)
|
|
}
|
|
|
|
func statDir[Ctx any](d *FsNodeDecl[Ctx], uid, gid, name string, ctx Ctx) (os.FileInfo, error) {
|
|
// Stat of the dir itself.
|
|
if name == "" || name == "." {
|
|
m := d.Mode
|
|
if m == 0 {
|
|
m = 0755
|
|
}
|
|
return &SyntheticFileInfo{Name_: d.Name, Mode_: m | os.ModeDir, IsDir_: true, UID_: uid, GID_: gid}, nil
|
|
}
|
|
|
|
child, _ := findChild(d, name, ctx)
|
|
if child == nil {
|
|
return nil, fmt.Errorf("%s: not found", name)
|
|
}
|
|
if child.Stat != nil {
|
|
return child.Stat(), nil
|
|
}
|
|
cm := child.Mode
|
|
if cm == 0 {
|
|
cm = 0755
|
|
}
|
|
cu := resolveUID(child, uid)
|
|
cg := resolveGID(child, gid)
|
|
hasFileHandlers := child.Read != nil || child.Write != nil || child.BlockOnce != nil || child.Stream != nil || child.Request != nil
|
|
isDir := len(child.Children) > 0 || (child.Bindings != nil && !hasFileHandlers)
|
|
if isDir {
|
|
return &SyntheticFileInfo{Name_: name, Mode_: cm | os.ModeDir, IsDir_: true, UID_: cu, GID_: cg}, nil
|
|
}
|
|
return &SyntheticFileInfo{Name_: name, Mode_: cm, UID_: cu, GID_: cg}, nil
|
|
}
|
|
|
|
func openDir[Ctx any](d *FsNodeDecl[Ctx], uid, gid string, name string, ctx Ctx) (File, error) {
|
|
child, childCtx := findChild(d, name, ctx)
|
|
if child == nil {
|
|
return nil, fmt.Errorf("%s: not found", name)
|
|
}
|
|
sub := build(child, resolveUID(child, uid), resolveGID(child, gid), childCtx)
|
|
return sub.Open("")
|
|
}
|
|
|
|
func deleteDir[Ctx any](d *FsNodeDecl[Ctx], name string, ctx Ctx) error {
|
|
child, childCtx := findChild(d, name, ctx)
|
|
if child == nil {
|
|
return fmt.Errorf("%s: not found", name)
|
|
}
|
|
if child.Remove != nil {
|
|
return child.Remove(childCtx)
|
|
}
|
|
return fmt.Errorf("%s: not removable", name)
|
|
}
|
|
|
|
func renameDir[Ctx any](d *FsNodeDecl[Ctx], old, new string, ctx Ctx) error {
|
|
child, _ := findChild(d, old, ctx)
|
|
if child == nil {
|
|
return fmt.Errorf("%s: not found", old)
|
|
}
|
|
if child.Rename != nil {
|
|
return child.Rename(new)
|
|
}
|
|
return fmt.Errorf("rename not supported")
|
|
}
|
|
|
|
// ── leaf ops ─────────────────────────────────────────────────────
|
|
|
|
func openLeaf[Ctx any](d *FsNodeDecl[Ctx], uid, gid string, mode os.FileMode, ctx Ctx, name string) (File, error) {
|
|
notBlocking := func(context.Context, string) ([]byte, string, error) {
|
|
return nil, "", fmt.Errorf("blocking read not supported")
|
|
}
|
|
|
|
// Read handler.
|
|
readFn := func() ([]byte, error) {
|
|
if d.Read != nil {
|
|
return d.Read(ctx)
|
|
}
|
|
return []byte{}, nil
|
|
}
|
|
|
|
// Write handler.
|
|
writeFn := func(data []byte) error {
|
|
if d.Write != nil {
|
|
return d.Write(ctx, data)
|
|
}
|
|
return fmt.Errorf("%s: read-only", d.Name)
|
|
}
|
|
|
|
// Blocking/Stream/Request handler.
|
|
var blockFn func(context.Context, string) ([]byte, string, error)
|
|
var isBlocking, isStreaming, isRequest bool
|
|
switch {
|
|
case d.BlockOnce != nil:
|
|
fn := d.BlockOnce
|
|
blockFn = func(c context.Context, b string) ([]byte, string, error) { return fn(ctx, c, b) }
|
|
isBlocking = true
|
|
case d.Stream != nil:
|
|
fn := d.Stream
|
|
blockFn = func(c context.Context, b string) ([]byte, string, error) { return fn(ctx, c, b) }
|
|
isStreaming = true
|
|
case d.Request != nil:
|
|
fn := d.Request
|
|
// Request: Write stores result, Read returns it.
|
|
var result []byte
|
|
blockFn = notBlocking
|
|
readFn = func() ([]byte, error) {
|
|
if result != nil {
|
|
r := result
|
|
result = nil
|
|
return r, nil
|
|
}
|
|
if d.Read != nil {
|
|
return d.Read(ctx)
|
|
}
|
|
return nil, nil
|
|
}
|
|
writeFn = func(data []byte) error {
|
|
r, err := fn(ctx, data)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
result = r
|
|
return nil
|
|
}
|
|
isRequest = true
|
|
default:
|
|
blockFn = notBlocking
|
|
}
|
|
|
|
return &FileConfig{
|
|
StatFn: func() (os.FileInfo, error) {
|
|
if d.Stat != nil {
|
|
return d.Stat(), nil
|
|
}
|
|
data, _ := readFn()
|
|
return &SyntheticFileInfo{Name_: name, Mode_: mode, Size_: int64(len(data)), UID_: uid, GID_: gid}, nil
|
|
},
|
|
ReadFn: readFn,
|
|
WriteFn: writeFn,
|
|
BlockingReadFn: blockFn,
|
|
BlockingRead_: isBlocking,
|
|
StreamMode_: isStreaming,
|
|
RequestResp_: isRequest,
|
|
}, nil
|
|
}
|
|
|
|
// ── helpers ──────────────────────────────────────────────────────
|
|
|
|
func findChild[Ctx any](d *FsNodeDecl[Ctx], name string, ctx Ctx) (*FsNodeDecl[Ctx], Ctx) {
|
|
// Handle nested paths recursively.
|
|
if i := strings.IndexByte(name, '/'); i >= 0 {
|
|
prefix := name[:i]
|
|
rest := name[i+1:]
|
|
child, childCtx := findChild(d, prefix, ctx)
|
|
if child == nil {
|
|
return nil, ctx
|
|
}
|
|
return findChild(child, rest, childCtx)
|
|
}
|
|
|
|
// Static children (non-template, non-blueprint).
|
|
for i := range d.Children {
|
|
if !isTemplateName(d.Children[i].Name) && d.Children[i].Name == name {
|
|
return &d.Children[i], ctx
|
|
}
|
|
}
|
|
|
|
// Blueprint children: find binding, apply to context, return cloned blueprint.
|
|
for i := range d.Children {
|
|
if isTemplateName(d.Children[i].Name) && d.Children[i].Bindings != nil {
|
|
bindings, err := d.Children[i].Bindings(ctx)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
for _, b := range bindings {
|
|
if b.Name == name || matchAlias(b.Aliases, name) {
|
|
// Clone the blueprint with binding identity and apply context.
|
|
clone := cloneBlueprint(&d.Children[i], b)
|
|
boundCtx := ctx
|
|
if b.Applier != nil {
|
|
boundCtx = b.Applier(ctx)
|
|
}
|
|
return clone, boundCtx
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil, ctx
|
|
}
|
|
|
|
// cloneBlueprint creates a copy of the blueprint with binding identity applied.
|
|
func cloneBlueprint[Ctx any](bp *FsNodeDecl[Ctx], b Binding[Ctx]) *FsNodeDecl[Ctx] {
|
|
clone := *bp
|
|
clone.Name = b.Name
|
|
clone.Aliases = b.Aliases
|
|
if b.UID != "" {
|
|
clone.UID = b.UID
|
|
}
|
|
if b.GID != "" {
|
|
clone.GID = b.GID
|
|
}
|
|
if b.Rename != nil {
|
|
clone.Rename = b.Rename
|
|
}
|
|
return &clone
|
|
}
|
|
|
|
func matchAlias(aliases []string, name string) bool {
|
|
for _, a := range aliases {
|
|
if a == name {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func resolveUID[Ctx any](d *FsNodeDecl[Ctx], parent string) string {
|
|
if d.UID != "" {
|
|
return d.UID
|
|
}
|
|
return parent
|
|
}
|
|
|
|
func resolveGID[Ctx any](d *FsNodeDecl[Ctx], parent string) string {
|
|
if d.GID != "" {
|
|
return d.GID
|
|
}
|
|
return parent
|
|
}
|
|
|
|
func isTemplateName(name string) bool {
|
|
return len(name) > 2 && name[0] == '{' && name[len(name)-1] == '}'
|
|
}
|