virtfs: eliminate Binding type

Each() now returns []FsNodeDecl directly. The Binding type was a
redundant subset of FsNodeDecl with a slightly different Remove
signature. Dynamic entries are now expressed uniformly — Remove,
Rename, Children, Aliases all live on FsNodeDecl like everything else.

Also added: Alias() and RenameNode() options, DirNode accepts
[]FsNodeDecl for passing pre-built child slices.
This commit is contained in:
Ollie Agent 2026-08-11 20:24:29 +02:00
parent 3a875da9f6
commit 5a09e603cd
6 changed files with 71 additions and 87 deletions

View File

@ -64,15 +64,13 @@ func readSessionConnected(s *SessionNode) ([]byte, error) {
return []byte("false\n"), nil
}
func sessionCtlHandler(s *SessionNode, removeFn func(), renameFn func(string) error) func([]byte) ([]byte, error) {
func sessionCtlHandler(s *SessionNode, removeFn func() error, renameFn func(string) error) func([]byte) ([]byte, error) {
handlers := map[string]func([]string) ([]byte, error){
"kill": func(_ []string) ([]byte, error) {
removeFn()
return []byte("ok\n"), nil
return []byte("ok\n"), removeFn()
},
".": func(_ []string) ([]byte, error) {
removeFn()
return []byte("ok\n"), nil
return []byte("ok\n"), removeFn()
},
"save": func(_ []string) ([]byte, error) {
s.Save()

View File

@ -94,7 +94,7 @@ func buildTreeSpec(rs *RootState) virtfs.FsNodeDecl {
virtfs.Read(func() ([]byte, error) { return readBypassPolicy(broker) }),
virtfs.Write(func(data []byte) error { return writeBypassPolicy(broker, data) }),
),
virtfs.Each("{reqid}", func() ([]virtfs.Binding, error) {
virtfs.Each("{reqid}", func() ([]virtfs.FsNodeDecl, error) {
return adaptBypassBindings(broker)
}),
),
@ -109,7 +109,7 @@ func buildTreeSpec(rs *RootState) virtfs.FsNodeDecl {
virtfs.Doc("Session index"),
virtfs.Read(readSessionIdx),
),
virtfs.Each("{sname}", func() ([]virtfs.Binding, error) {
virtfs.Each("{sname}", func() ([]virtfs.FsNodeDecl, error) {
return buildSessionBindings(rs, broker)
}),
),
@ -117,18 +117,19 @@ func buildTreeSpec(rs *RootState) virtfs.FsNodeDecl {
}
// buildSessionBindings produces bindings for each session.
func buildSessionBindings(rs *RootState, broker *bypass.Broker) ([]virtfs.Binding, error) {
func buildSessionBindings(rs *RootState, broker *bypass.Broker) ([]virtfs.FsNodeDecl, error) {
sessions := session.Sessions()
var out []virtfs.Binding
var out []virtfs.FsNodeDecl
for name, sess := range sessions {
node := rs.GetOrCreateNode(sess)
s := node // capture
n := name // capture
removeFn := func() {
removeFn := func() error {
session.Kill(n)
rs.RemoveNode(n)
return nil
}
renameFn := func(newName string) error {
if err := session.Rename(n, newName); err != nil {
@ -138,7 +139,7 @@ func buildSessionBindings(rs *RootState, broker *bypass.Broker) ([]virtfs.Bindin
return nil
}
out = append(out, virtfs.Binding{
out = append(out, virtfs.FsNodeDecl{
Name: sess.Name(),
Aliases: []string{sess.ID},
Remove: removeFn,
@ -154,7 +155,7 @@ func buildSessionChildren(
s *SessionNode,
rs *RootState,
broker *bypass.Broker,
removeFn func(),
removeFn func() error,
renameFn func(string) error,
) []virtfs.FsNodeDecl {
return []virtfs.FsNodeDecl{
@ -188,7 +189,7 @@ func buildSessionChildren(
virtfs.FileNode("idx", 0444,
virtfs.Read(func() ([]byte, error) { return readAgentIdx(s.Session) }),
),
virtfs.Each("{aname}", func() ([]virtfs.Binding, error) {
virtfs.Each("{aname}", func() ([]virtfs.FsNodeDecl, error) {
return buildAgentBindings(s, rs)
}),
),
@ -196,9 +197,9 @@ func buildSessionChildren(
}
// buildAgentBindings produces bindings for each agent in a session.
func buildAgentBindings(s *SessionNode, rs *RootState) ([]virtfs.Binding, error) {
func buildAgentBindings(s *SessionNode, rs *RootState) ([]virtfs.FsNodeDecl, error) {
agents := s.Agents()
var out []virtfs.Binding
var out []virtfs.FsNodeDecl
for _, ag := range agents {
a := ag
@ -209,12 +210,13 @@ func buildAgentBindings(s *SessionNode, rs *RootState) ([]virtfs.Binding, error)
wireAgentEvents(s.ID, a, al)
}
out = append(out, virtfs.Binding{
out = append(out, virtfs.FsNodeDecl{
Name: a.Name(),
Aliases: []string{a.ID()},
Remove: func() {
Remove: func() error {
s.RemoveAgent(a.ID())
session.PublishEvent("session."+s.ID+".agent."+a.ID()+".kill", "")
return nil
},
Children: buildAgentChildren(a, al, s),
})
@ -283,7 +285,7 @@ func buildAgentChildren(a *agent.Agent, al *AgentLog, s *SessionNode) []virtfs.F
virtfs.Read(func() ([]byte, error) { return readAgentID(a) }),
),
virtfs.DirNode("proc",
virtfs.Each("{pid}", func() ([]virtfs.Binding, error) {
virtfs.Each("{pid}", func() ([]virtfs.FsNodeDecl, error) {
return adaptProcessBindings(s)
}),
),
@ -291,15 +293,15 @@ func buildAgentChildren(a *agent.Agent, al *AgentLog, s *SessionNode) []virtfs.F
}
// adaptBypassBindings returns bindings for pending bypass requests.
func adaptBypassBindings(broker *bypass.Broker) ([]virtfs.Binding, error) {
func adaptBypassBindings(broker *bypass.Broker) ([]virtfs.FsNodeDecl, error) {
if broker == nil {
return nil, errNoBypass
}
pending := broker.Pending()
var out []virtfs.Binding
var out []virtfs.FsNodeDecl
for _, r := range pending {
req := r
out = append(out, virtfs.Binding{
out = append(out, virtfs.FsNodeDecl{
Name: req.ID,
Children: []virtfs.FsNodeDecl{
virtfs.FileNode("request", 0666,
@ -317,7 +319,7 @@ func adaptBypassBindings(broker *bypass.Broker) ([]virtfs.Binding, error) {
}
// adaptProcessBindings returns bindings for detached processes.
func adaptProcessBindings(s *SessionNode) ([]virtfs.Binding, error) {
func adaptProcessBindings(s *SessionNode) ([]virtfs.FsNodeDecl, error) {
if s == nil {
return nil, nil
}
@ -327,12 +329,12 @@ func adaptProcessBindings(s *SessionNode) ([]virtfs.Binding, error) {
}
defer conn.Close()
raw := conn.ListDetachedRaw()
var out []virtfs.Binding
var out []virtfs.FsNodeDecl
for _, r := range raw {
if m, ok := r.(map[string]any); ok {
if id, ok := m["pid"].(int); ok {
pid := id
out = append(out, virtfs.Binding{
out = append(out, virtfs.FsNodeDecl{
Name: strconv.Itoa(pid),
Children: []virtfs.FsNodeDecl{
virtfs.FileNode("out", 0444,

View File

@ -58,16 +58,16 @@ func Spec(srv *Server) virtfs.FsNodeDecl {
return []byte(result), nil
}),
),
virtfs.Each("{pid}", func() ([]virtfs.Binding, error) {
virtfs.Each("{pid}", func() ([]virtfs.FsNodeDecl, error) {
pids := srv.Fs.ListProcs()
var bindings []virtfs.Binding
var out []virtfs.FsNodeDecl
for _, pid := range pids {
proc := srv.Fs.GetProc(pid)
if proc == nil {
continue
}
p := proc
bindings = append(bindings, virtfs.Binding{
out = append(out, virtfs.FsNodeDecl{
Name: strconv.Itoa(pid),
Children: []virtfs.FsNodeDecl{
virtfs.FileNode("out", 0444,
@ -98,7 +98,7 @@ func Spec(srv *Server) virtfs.FsNodeDecl {
},
})
}
return bindings, nil
return out, nil
}),
),
)

View File

@ -128,16 +128,15 @@ func listDir(d *FsNodeDecl, uid, gid string) ([]os.DirEntry, error) {
for _, c := range d.Children {
if isTemplateName(c.Name) {
if c.Bindings != nil {
bindings, err := c.Bindings()
nodes, err := c.Bindings()
if err != nil {
continue
}
for _, b := range bindings {
// If binding has children, it's a directory; otherwise check template for file handlers
if len(b.Children) > 0 {
entries = append(entries, dirEntry(b.Name, 0755))
for _, n := range nodes {
if len(n.Children) > 0 || n.Bindings != nil {
entries = append(entries, dirEntry(n.Name, 0755))
} else {
entries = append(entries, fileEntry(b.Name, c.Mode))
entries = append(entries, fileEntry(n.Name, c.Mode))
}
}
}
@ -321,31 +320,16 @@ func findChild(d *FsNodeDecl, name string) *FsNodeDecl {
}
}
// Dynamic children (Each): find matching binding by name/alias.
// Dynamic children (Each): find matching entry by name/alias.
for i := range d.Children {
if isTemplateName(d.Children[i].Name) && d.Children[i].Bindings != nil {
bindings, err := d.Children[i].Bindings()
entries, err := d.Children[i].Bindings()
if err != nil {
continue
}
for _, b := range bindings {
if b.Name == name || matchAlias(b.Aliases, name) {
// Build a synthetic FsNodeDecl from the binding's children.
node := FsNodeDecl{
Name: b.Name,
Aliases: b.Aliases,
UID: b.UID,
GID: b.GID,
Children: b.Children,
}
if b.Remove != nil {
rm := b.Remove
node.Remove = func() error { rm(); return nil }
}
if b.Rename != nil {
node.Rename = b.Rename
}
return &node
for j := range entries {
if entries[j].Name == name || matchAlias(entries[j].Aliases, name) {
return &entries[j]
}
}
}

View File

@ -12,11 +12,11 @@
// FileNode("version", 0444, Read(func() ([]byte, error) {
// return []byte("1.0\n"), nil
// })),
// Each("{user}", func() ([]Binding, error) {
// var out []Binding
// Each("{user}", func() ([]FsNodeDecl, error) {
// var out []FsNodeDecl
// for _, u := range users {
// user := u
// out = append(out, Binding{
// out = append(out, FsNodeDecl{
// Name: user.Name,
// Children: []FsNodeDecl{
// FileNode("name", 0444, Read(func() ([]byte, error) {
@ -52,8 +52,8 @@ type FsNodeDecl struct {
Stat func() os.FileInfo
// Twalk — children (implies directory; at most one set).
Children []FsNodeDecl // static, known at compile time
Bindings func() ([]Binding, error) // Each: dynamic children
Children []FsNodeDecl // static, known at compile time
Bindings func() ([]FsNodeDecl, error) // Each: dynamic children
// Tread (non-blocking) + Twrite.
Read func() ([]byte, error)
@ -74,17 +74,6 @@ type FsNodeDecl struct {
// NodeOption configures a FsNodeDecl at construction time.
type NodeOption func(*FsNodeDecl)
// Binding provides identity and pre-bound children for one iteration of Each.
type Binding struct {
Name string
Aliases []string
UID string
GID string
Children []FsNodeDecl // fully-bound subtree (handlers are closures)
Remove func()
Rename func(string) error
}
// ── EDSL constructors ────────────────────────────────────────────
// DirNode declares a static directory node.
@ -94,6 +83,8 @@ func DirNode(name string, args ...any) FsNodeDecl {
switch v := a.(type) {
case FsNodeDecl:
d.Children = append(d.Children, v)
case []FsNodeDecl:
d.Children = append(d.Children, v...)
case NodeOption:
v(&d)
}
@ -102,9 +93,8 @@ func DirNode(name string, args ...any) FsNodeDecl {
}
// Each declares a directory whose children are produced dynamically.
// The bindings function returns pre-bound children for each instance.
func Each(name string, bindings func() ([]Binding, error), opts ...NodeOption) FsNodeDecl {
d := FsNodeDecl{Name: name, Bindings: bindings}
func Each(name string, list func() ([]FsNodeDecl, error), opts ...NodeOption) FsNodeDecl {
d := FsNodeDecl{Name: name, Bindings: list}
for _, o := range opts {
o(&d)
}
@ -152,6 +142,16 @@ func RemoveNode(fn func() error) NodeOption {
return func(d *FsNodeDecl) { d.Remove = fn }
}
// RenameNode sets the rename handler for a node.
func RenameNode(fn func(string) error) NodeOption {
return func(d *FsNodeDecl) { d.Rename = fn }
}
// Alias adds alternate lookup names for this node.
func Alias(names ...string) NodeOption {
return func(d *FsNodeDecl) { d.Aliases = append(d.Aliases, names...) }
}
// StatOverride sets a custom stat handler.
func StatOverride(fn func() os.FileInfo) NodeOption {
return func(d *FsNodeDecl) { d.Stat = fn }

View File

@ -183,10 +183,10 @@ func TestFileConfigModes(t *testing.T) {
func TestDeleteDir(t *testing.T) {
var deleted bool
spec := DirNode("/",
Each("{id}", func() ([]Binding, error) {
return []Binding{{
Each("{id}", func() ([]FsNodeDecl, error) {
return []FsNodeDecl{{
Name: "item",
Remove: func() { deleted = true },
Remove: func() error { deleted = true; return nil },
Children: []FsNodeDecl{
FileNode("data", 0444),
},
@ -215,8 +215,8 @@ func TestDeleteDirNotFound(t *testing.T) {
func TestRenameDir(t *testing.T) {
var newName string
spec := DirNode("/",
Each("{id}", func() ([]Binding, error) {
return []Binding{{
Each("{id}", func() ([]FsNodeDecl, error) {
return []FsNodeDecl{{
Name: "item",
Rename: func(n string) error { newName = n; return nil },
Children: []FsNodeDecl{
@ -462,11 +462,11 @@ func TestEachClosureCapture(t *testing.T) {
items := []Item{{"foo", "hello"}, {"bar", "world"}}
spec := DirNode("/",
Each("{item}", func() ([]Binding, error) {
var out []Binding
Each("{item}", func() ([]FsNodeDecl, error) {
var out []FsNodeDecl
for _, item := range items {
it := item
out = append(out, Binding{
out = append(out, FsNodeDecl{
Name: it.Name,
Children: []FsNodeDecl{
FileNode("value", 0444, Read(func() ([]byte, error) {
@ -510,8 +510,8 @@ func TestEachClosureCapture(t *testing.T) {
func TestEachDirListing(t *testing.T) {
spec := DirNode("/",
Each("{item}", func() ([]Binding, error) {
return []Binding{
Each("{item}", func() ([]FsNodeDecl, error) {
return []FsNodeDecl{
{Name: "alpha", Children: []FsNodeDecl{FileNode("x", 0444)}},
{Name: "beta", Children: []FsNodeDecl{FileNode("x", 0444)}},
}, nil
@ -533,8 +533,8 @@ func TestEachDirListing(t *testing.T) {
func TestEachWithAliases(t *testing.T) {
spec := DirNode("/",
Each("{item}", func() ([]Binding, error) {
return []Binding{{
Each("{item}", func() ([]FsNodeDecl, error) {
return []FsNodeDecl{{
Name: "friendly",
Aliases: []string{"uuid-123"},
Children: []FsNodeDecl{
@ -561,8 +561,8 @@ func TestEachWithAliases(t *testing.T) {
func TestStatDir(t *testing.T) {
spec := DirNode("/",
Each("{item}", func() ([]Binding, error) {
return []Binding{{
Each("{item}", func() ([]FsNodeDecl, error) {
return []FsNodeDecl{{
Name: "sub",
Children: []FsNodeDecl{FileNode("file", 0444)},
}}, nil