From e21a6e0a2fc135aedffffef240c69d5301747206 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Fri, 9 Oct 2026 18:06:52 +0000 Subject: [PATCH 1/2] Read bounded trees through File handles --- .../internal/agenthost/tree_linux_test.go | 263 +++++--- apps/daemon/internal/agenthost/world_linux.go | 122 ++-- apps/sandboxio/internal/fileservice/files.go | 27 +- .../internal/fileservice/namespace.go | 29 +- .../sandboxio/internal/fileservice/service.go | 127 +++- apps/sandboxio/internal/fileservice/tree.go | 332 ++++++++++ .../internal/fileservice/tree_test.go | 625 ++++++++++++++++++ docs/file-access-protocol.md | 39 +- docs/zh/file-access-protocol.md | 41 +- internal/sandboxfs/client.go | 6 +- internal/sandboxfs/protocol.go | 383 ++++++++++- internal/sandboxfs/protocol_test.go | 9 +- internal/sandboxfs/server_test.go | 20 +- .../sandboxfs/testdata/describe_response.hex | 4 +- .../sandboxfs/testdata/opentree_request.hex | 7 + .../sandboxfs/testdata/opentree_response.hex | 4 + internal/sandboxfs/testdata/tree_result.hex | 14 + internal/sandboxfs/tree_test.go | 319 +++++++++ 18 files changed, 2151 insertions(+), 220 deletions(-) create mode 100644 apps/sandboxio/internal/fileservice/tree.go create mode 100644 apps/sandboxio/internal/fileservice/tree_test.go create mode 100644 internal/sandboxfs/testdata/opentree_request.hex create mode 100644 internal/sandboxfs/testdata/opentree_response.hex create mode 100644 internal/sandboxfs/testdata/tree_result.hex create mode 100644 internal/sandboxfs/tree_test.go diff --git a/apps/daemon/internal/agenthost/tree_linux_test.go b/apps/daemon/internal/agenthost/tree_linux_test.go index 9e42e3d5a..379f1f427 100644 --- a/apps/daemon/internal/agenthost/tree_linux_test.go +++ b/apps/daemon/internal/agenthost/tree_linux_test.go @@ -25,7 +25,8 @@ type treeService struct { opens, releases, active, peak atomic.Int32 read func(context.Context) error opened func(context.Context) - listed func(*sandboxfs.ReadDirResponse) + shortRead bool + caps func(*sandboxfs.Capabilities) readDone chan struct{} releaseError bool maxHandles uint32 @@ -37,6 +38,9 @@ func (s *treeService) Describe(ctx context.Context, a sandboxfs.Attachment, q *s if err == nil && s.maxHandles != 0 { r.Capabilities.MaxOpenHandles = s.maxHandles } + if err == nil && s.caps != nil { + s.caps(&r.Capabilities) + } return r, err } func (s *treeService) Open(ctx context.Context, a sandboxfs.Attachment, q *sandboxfs.OpenRequest) (*sandboxfs.OpenResponse, error) { @@ -75,12 +79,27 @@ func (s *treeService) Read(ctx context.Context, a sandboxfs.Attachment, q *sandb return nil, err } } - return s.Service.Read(ctx, a, q) + r, err := s.Service.Read(ctx, a, q) + if err == nil && s.shortRead && len(r.Data) != 0 { + r.Data = r.Data[:len(r.Data)-1] + } + return r, err } -func (s *treeService) ReadDir(ctx context.Context, a sandboxfs.Attachment, q *sandboxfs.ReadDirRequest) (*sandboxfs.ReadDirResponse, error) { - r, err := s.Service.ReadDir(ctx, a, q) - if err == nil && s.listed != nil { - s.listed(r) +func (s *treeService) OpenTree(ctx context.Context, a sandboxfs.Attachment, q *sandboxfs.OpenTreeRequest) (*sandboxfs.OpenTreeResponse, error) { + if s.maxHandles != 0 && s.held.Add(1) > int32(s.maxHandles) { + s.held.Add(-1) + s.refused.Add(1) + return nil, sandboxfs.NewFailure(sandboxfs.CodeResourceExhausted, sandboxwire.EffectNone, "declared handle limit reached") + } + r, err := s.Service.OpenTree(ctx, a, q) + if err != nil && s.maxHandles != 0 { + s.held.Add(-1) + } + if err == nil { + s.opens.Add(1) + if s.opened != nil { + s.opened(ctx) + } } return r, err } @@ -154,117 +173,72 @@ func treeReleased(t *testing.T, w *world, s *treeService) { } } -func TestReadTreeConcurrent(t *testing.T) { - for _, tc := range []struct { - mode string - limit int - }{ - {"success", 4}, {"read_error", 4}, {"parent_cancel", 4}, {"open_cancel", 4}, - {"success", 1}, {"success", 2}, {"success", 3}, - } { - mode, limit := tc.mode, tc.limit - t.Run(fmt.Sprintf("%s/handles_%d", mode, limit), func(t *testing.T) { - s := &treeService{readDone: make(chan struct{}, 8), maxHandles: uint32(limit)} - files := map[string]string{} - for i := range 8 { - files[fmt.Sprintf("dir/%02d", i)] = fmt.Sprint(i) - } - w, _ := treeWorld(t, s, files) +func TestReadTreeResultOwnership(t *testing.T) { + for _, mode := range []string{"success", "read_error", "parent_cancel", "acquire_cancel", "short_read"} { + t.Run(mode, func(t *testing.T) { + s := &treeService{maxHandles: 1, readDone: make(chan struct{}, 1)} + contents := map[string]string{"a": "one", "dir/b": "two", "dir/nested/c": "three", "z": "four"} + w, _ := treeWorld(t, s, contents) ctx, cancel := context.WithCancel(t.Context()) defer cancel() - entered := make(chan struct{}, 8) - gate := make(chan struct{}) - gateClosed := false - defer func() { - if !gateClosed { - close(gate) - } - }() - if mode == "open_cancel" { + entered := make(chan struct{}, 1) + if mode == "acquire_cancel" { s.opened = func(ctx context.Context) { entered <- struct{}{}; <-ctx.Done() } - } else { - var first atomic.Bool - s.read = func(ctx context.Context) error { - entered <- struct{}{} - select { - case <-ctx.Done(): - return ctx.Err() - case <-gate: - } - if mode == "read_error" && first.CompareAndSwap(false, true) { - return sandboxfs.NewErrnoFailure(sandboxfs.ErrnoIO, sandboxwire.EffectNone, "read refused") - } - return nil + } + if mode == "parent_cancel" { + s.read = func(ctx context.Context) error { entered <- struct{}{}; <-ctx.Done(); return ctx.Err() } + } + if mode == "read_error" { + s.read = func(context.Context) error { + return sandboxfs.NewErrnoFailure(sandboxfs.ErrnoIO, sandboxwire.EffectNone, "read refused") } } + s.shortRead = mode == "short_read" type result struct { files []agentbundle.File err error } done := make(chan result, 1) go func() { f, e := w.readTree(ctx, w.root, true); done <- result{f, e} }() - for range limit { + if mode == "acquire_cancel" || mode == "parent_cancel" { select { case <-entered: case <-time.After(5 * time.Second): - t.Fatal("declared number of reads did not start") + t.Fatal("operation did not enter") } - } - if s.opens.Load() != int32(limit) { - t.Fatalf("opened %d files before releasing bound", s.opens.Load()) - } - if mode == "parent_cancel" || mode == "open_cancel" { - // A completed call fences the preceding request writes: this - // case exercises cancellation after dispatch, not a torn frame. if _, err := w.c.Describe(t.Context(), &sandboxfs.DescribeRequest{}); err != nil { t.Fatal(err) } cancel() - } else { - close(gate) - gateClosed = true } - var r result + var got result select { - case r = <-done: + case got = <-done: case <-time.After(5 * time.Second): - t.Fatal("read did not join") + t.Fatal("read did not settle") } if mode == "success" { - if r.err != nil || len(r.files) != len(files) { - t.Fatalf("tree result: %v %v", r.files, r.err) + if got.err != nil || len(got.files) != len(contents) { + t.Fatalf("read tree: %v %v", got.files, got.err) } - names := make([]string, 0, len(files)) - for name := range files { - names = append(names, name) - } - slices.Sort(names) - for i, f := range r.files { - if f.Path != names[i] || string(f.Data) != files[f.Path] || !f.Executable { - t.Fatalf("incorrect ordered file: %+v", f) + names := []string{"a", "dir/b", "dir/nested/c", "z"} + for i, f := range got.files { + if f.Path != names[i] || string(f.Data) != contents[f.Path] || !f.Executable { + t.Fatalf("file: %+v", f) } } - } else if r.err == nil || r.files != nil { - t.Fatalf("failure returned tree: %v %v", r.files, r.err) - } - if mode != "open_cancel" && s.peak.Load() != int32(limit) { - t.Fatalf("read concurrency %d, want %d", s.peak.Load(), limit) + } else if got.err == nil || got.files != nil { + t.Fatalf("failed read returned files: %v %v", got.files, got.err) } - if mode != "open_cancel" { - n := 8 - if mode == "parent_cancel" { - n = limit - } - for range n { - select { - case <-s.readDone: - case <-time.After(5 * time.Second): - t.Fatal("File read handler did not finish") - } + if mode != "acquire_cancel" { + select { + case <-s.readDone: + case <-time.After(5 * time.Second): + t.Fatal("read handler not finished") } } - if s.held.Load() != 0 || s.refused.Load() != 0 { - t.Fatalf("handle bound exceeded or leaked: held=%d refused=%d", s.held.Load(), s.refused.Load()) + if s.opens.Load() != 1 || s.held.Load() != 0 || s.refused.Load() != 0 { + t.Fatalf("handle ownership: opens=%d held=%d refused=%d", s.opens.Load(), s.held.Load(), s.refused.Load()) } treeReleased(t, w, s) }) @@ -272,7 +246,7 @@ func TestReadTreeConcurrent(t *testing.T) { } func TestReadTreeValidation(t *testing.T) { - for _, mode := range []string{"writable", "symlink", "size_sum", "entry_count", "grow", "shrink", "readback_tamper", "release_error"} { + for _, mode := range []string{"writable", "symlink", "size_sum", "entry_count", "readback_tamper", "release_error"} { t.Run(mode, func(t *testing.T) { s := &treeService{} w, dir := treeWorld(t, s, map[string]string{"a": "original", "b": "second"}) @@ -286,9 +260,16 @@ func TestReadTreeValidation(t *testing.T) { t.Fatal(err) } case "size_sum": - s.listed = func(r *sandboxfs.ReadDirResponse) { - for i := range r.Entries { - r.Entries[i].Entry.Attr.Size = uint64(agentbundle.MaxExpandedBytes) + for _, name := range []string{"a", "b"} { + p := filepath.Join(dir, name) + if err := os.Chmod(p, 0600); err != nil { + t.Fatal(err) + } + if err := os.Truncate(p, agentbundle.MaxExpandedBytes); err != nil { + t.Fatal(err) + } + if err := os.Chmod(p, 0400); err != nil { + t.Fatal(err) } } case "entry_count": @@ -297,18 +278,6 @@ func TestReadTreeValidation(t *testing.T) { t.Fatal(err) } } - case "grow", "shrink": - s.listed = func(r *sandboxfs.ReadDirResponse) { - for i := range r.Entries { - if string(r.Entries[i].Name) == "a" { - if mode == "grow" { - r.Entries[i].Entry.Attr.Size-- - } else { - r.Entries[i].Entry.Attr.Size++ - } - } - } - } case "readback_tamper": if _, err := w.readTree(t.Context(), w.root, true); err != nil { t.Fatal(err) @@ -333,9 +302,95 @@ func TestReadTreeValidation(t *testing.T) { return } if slices.Contains([]string{"writable", "symlink", "size_sum", "entry_count"}, mode) && s.opens.Load() != 0 { - t.Fatalf("opened files before validating metadata: %d", s.opens.Load()) + t.Fatalf("published invalid result: %d", s.opens.Load()) } treeReleased(t, w, s) }) } } + +func TestReadTreeCapabilitiesRequired(t *testing.T) { + for _, mode := range []string{"unsupported", "entries", "bytes"} { + t.Run(mode, func(t *testing.T) { + server, err := fileservicetest.New(t.TempDir()) + if err != nil { + t.Fatal(err) + } + defer server.Close() + s := &treeService{caps: func(c *sandboxfs.Capabilities) { + switch mode { + case "unsupported": + c.MaxTreeEntries = 0 + c.MaxTreeDataBytes = 0 + case "entries": + c.MaxTreeEntries = agentbundle.MaxFiles - 1 + case "bytes": + c.MaxTreeDataBytes = agentbundle.MaxExpandedBytes - 1 + } + }} + server.Intercept(func(real sandboxfs.Service) sandboxfs.Service { s.Service = real; return s }) + stream, err := server.Dial(t.Context()) + if err != nil { + t.Fatal(err) + } + uncertain := false + w, err := attachWorld(t.Context(), stream, &uncertain) + var failure *sandboxfs.Failure + if w != nil || !errors.As(err, &failure) || failure.Code != sandboxfs.CodeUnsupported { + t.Fatalf("unsupported trees: world=%v err=%v", w, err) + } + }) + } +} + +func TestReadTreeLargeFileAndFreshRead(t *testing.T) { + s := &treeService{maxHandles: 1} + w, dir := treeWorld(t, s, nil) + name := filepath.Join(dir, "large") + f, err := os.OpenFile(name, os.O_CREATE|os.O_WRONLY, 0400) + if err != nil { + t.Fatal(err) + } + chunk := make([]byte, 64<<10) + for i := range chunk { + chunk[i] = byte(i % 251) + } + for range agentbundle.MaxExpandedBytes / len(chunk) { + if _, err = f.Write(chunk); err != nil { + f.Close() + t.Fatal(err) + } + } + if err = f.Close(); err != nil { + t.Fatal(err) + } + got, err := w.readTree(t.Context(), w.root, true) + if err != nil || len(got) != 1 || len(got[0].Data) != agentbundle.MaxExpandedBytes { + t.Fatalf("large tree: files=%d err=%v", len(got), err) + } + if got[0].Path != "large" || got[0].Executable { + t.Fatal("large file attributes changed") + } + for i, b := range got[0].Data { + if b != chunk[i%len(chunk)] { + t.Fatalf("byte %d changed", i) + } + } + if err = os.Chmod(name, 0600); err != nil { + t.Fatal(err) + } + if err = os.WriteFile(name, []byte("replacement"), 0400); err != nil { + t.Fatal(err) + } + if err = os.Chmod(name, 0400); err != nil { + t.Fatal(err) + } + next, err := w.readTree(t.Context(), w.root, true) + if err != nil || len(next) != 1 || string(next[0].Data) != "replacement" { + t.Fatalf("fresh read: %v %v", next, err) + } + if len(got[0].Data) != agentbundle.MaxExpandedBytes || got[0].Data[0] != 0 { + t.Fatal("later read mutated caller-owned bytes") + } + treeReleased(t, w, s) +} diff --git a/apps/daemon/internal/agenthost/world_linux.go b/apps/daemon/internal/agenthost/world_linux.go index 6c89e757c..eb4331290 100644 --- a/apps/daemon/internal/agenthost/world_linux.go +++ b/apps/daemon/internal/agenthost/world_linux.go @@ -61,6 +61,10 @@ func attachWorld(ctx context.Context, stream io.ReadWriteCloser, uncertain *bool w.c.Close() return nil, errors.New("agenthost: the File service does not serve a writable world") } + if caps.MaxTreeEntries < agentbundle.MaxFiles || caps.MaxTreeDataBytes < agentbundle.MaxExpandedBytes { + w.c.Close() + return nil, sandboxfs.NewFailure(sandboxfs.CodeUnsupported, sandboxwire.EffectNone, "File service does not support bounded Environment trees") + } w.caps = caps a, err := w.c.Attach(ctx, &sandboxfs.AttachRequest{Export: sandboxfs.WorldExport}) if err != nil { @@ -439,67 +443,89 @@ func (w *world) sorted(ctx context.Context, dir sandboxfs.NodeRef, limit int) ([ return entries, err } -// readTree reads the tree at dir as agentcapabilities.ReadTree reads a local -// one: at most agentbundle.MaxFiles entries and agentbundle.MaxExpandedBytes, -// regular files and directories only, files without write bits when -// immutable, in fs.WalkDir's order. -func (w *world) readTree(ctx context.Context, dir sandboxfs.NodeRef, immutable bool) ([]agentbundle.File, error) { - t := treeReader{w: w, immutable: immutable, entries: 1} - if err := t.walk(ctx, dir, ""); err != nil { +// readTree reads a bounded tree through one owned File result handle. The File +// protocol validates its structure; capability interpretation stays with the owner. +func (w *world) readTree(ctx context.Context, dir sandboxfs.NodeRef, immutable bool) (files []agentbundle.File, err error) { + q := sandboxfs.OpenTreeRequest{Handle: w.handles.Next(), Node: dir, MaxEntries: agentbundle.MaxFiles, + MaxDataBytes: agentbundle.MaxExpandedBytes, RequireReadOnlyFiles: immutable} + result, err := w.c.OpenTree(ctx, &q) + var failure *sandboxfs.Failure + if errors.As(err, &failure) && failure.Effect == sandboxwire.EffectNone { return nil, err } - // Enumeration owns node references; only reads of the retained entries - // run concurrently. Join every read before the owner can forget them. - var reads errgroup.Group - reads.SetLimit(int(min(uint32(4), w.caps.MaxOpenHandles))) - for i, entry := range t.nodes { - reads.Go(func() error { - body, err := w.readEntry(ctx, entry, int64(entry.Attr.Size)) - if err == nil { - t.files[i].Data = body - } - return err - }) + defer func() { + err = errors.Join(err, w.closeHandle(ctx, q.Handle, false)) + if err != nil { + files = nil + } + }() + if err != nil { + return nil, err } - if err := reads.Wait(); err != nil { + reader := &treeResultReader{ctx: ctx, w: w, handle: q.Handle, remaining: result.Size} + decoder, err := sandboxfs.NewTreeDecoder(reader, q, w.caps, result.Size) + if err != nil { return nil, err } - return t.files, nil + paths := make([]string, 0) + files = []agentbundle.File{} + for { + record, body, readErr := decoder.Next() + if readErr == io.EOF { + return files, nil + } + if readErr != nil { + return nil, readErr + } + name := string(record.Name) + if len(paths) != 0 && paths[record.Parent] != "" { + name = paths[record.Parent] + "/" + name + } + paths = append(paths, name) + if !isType(record.Attr, sandboxfs.ModeRegular) { + continue + } + data := make([]byte, int(record.Attr.Size)) + if _, err := io.ReadFull(body, data); err != nil { + return nil, err + } + files = append(files, agentbundle.File{Path: name, Data: data, Executable: record.Attr.Mode&0o111 != 0}) + } } -type treeReader struct { - w *world - immutable bool - files []agentbundle.File - nodes []sandboxfs.Entry - entries int - total int +// treeResultReader fetches bounded chunks while the protocol decoder consumes +// records directly into their final file buffers. It never buffers the whole tree. +type treeResultReader struct { + ctx context.Context + w *world + handle sandboxfs.HandleID + remaining, offset uint64 + buffer []byte } -func (t *treeReader) walk(ctx context.Context, dir sandboxfs.NodeRef, prefix string) error { - entries, err := t.w.sorted(ctx, dir, agentbundle.MaxFiles) - if err != nil { - return err +func (r *treeResultReader) Read(dst []byte) (int, error) { + if len(dst) == 0 { + return 0, nil } - for _, e := range entries { - if t.entries++; t.entries > agentbundle.MaxFiles { - return fs.ErrInvalid + if len(r.buffer) == 0 { + if r.remaining == 0 { + return 0, io.EOF } - name, attr := prefix+string(e.Name), e.Entry.Attr - switch { - case isType(attr, sandboxfs.ModeDirectory): - if err := t.walk(ctx, e.Entry.Node, name+"/"); err != nil { - return err - } - case !isType(attr, sandboxfs.ModeRegular) || t.immutable && attr.Mode&0o222 != 0 || attr.Size > uint64(agentbundle.MaxExpandedBytes-t.total): - return fs.ErrInvalid - default: - t.total += int(attr.Size) - t.nodes = append(t.nodes, *e.Entry) - t.files = append(t.files, agentbundle.File{Path: name, Executable: attr.Mode&0o111 != 0}) + size := uint32(min(uint64(r.w.caps.MaxReadBytes), r.remaining)) + reply, err := r.w.c.Read(r.ctx, &sandboxfs.ReadRequest{Handle: r.handle, Offset: r.offset, Size: size}) + if err != nil { + return 0, err + } + if len(reply.Data) != int(size) { + return 0, io.ErrUnexpectedEOF } + r.buffer = reply.Data + r.remaining -= uint64(size) + r.offset += uint64(size) } - return nil + n := copy(dst, r.buffer) + r.buffer = r.buffer[n:] + return n, nil } // writeTree creates name below dir, which must not exist, with files: its diff --git a/apps/sandboxio/internal/fileservice/files.go b/apps/sandboxio/internal/fileservice/files.go index 65d336dd1..fbe12f12b 100644 --- a/apps/sandboxio/internal/fileservice/files.go +++ b/apps/sandboxio/internal/fileservice/files.go @@ -188,7 +188,7 @@ func (s *Service) Read(_ context.Context, a sandboxfs.Attachment, r *sandboxfs.R } buf := make([]byte, r.Size) total := 0 - err = use(h.f, errStaleHandle, func(fd int) error { + err = h.use(func(fd int) error { for total < len(buf) { n, err := eintr(func() (int, error) { return unix.Pread(fd, buf[total:], int64(r.Offset)+int64(total)) }) if err != nil { @@ -220,13 +220,16 @@ func (s *Service) Write(_ context.Context, a sandboxfs.Attachment, r *sandboxfs. if err != nil { return nil, err } + if h.tree { + return nil, failure(unix.EROFS, none) + } if !r.Append && r.Offset > math.MaxInt64-uint64(len(r.Data)) { return nil, sandboxfs.NewFailure(sandboxfs.CodeInvalidArgument, none, "write ends beyond 2^63-1") } h.writeMu.Lock() defer h.writeMu.Unlock() total := 0 - err = use(h.f, errStaleHandle, func(fd int) error { + err = h.use(func(fd int) error { if err := setAppend(fd, r.Append); err != nil { return err } @@ -279,8 +282,11 @@ func (s *Service) Flush(_ context.Context, a sandboxfs.Attachment, r *sandboxfs. if err != nil { return nil, err } + if h.tree { + return &sandboxfs.FlushResponse{}, nil + } effect := none - err = use(h.f, errStaleHandle, func(fd int) error { + err = h.use(func(fd int) error { dup, err := unix.FcntlInt(uintptr(fd), unix.F_DUPFD_CLOEXEC, 0) if err != nil { return err @@ -303,12 +309,15 @@ func (s *Service) Fsync(_ context.Context, a sandboxfs.Attachment, r *sandboxfs. if err != nil { return nil, err } + if h.tree { + return nil, unsupported("tree result operation") + } sync := unix.Fsync if r.DataOnly { sync = unix.Fdatasync } effect := none - err = use(h.f, errStaleHandle, func(fd int) error { + err = h.use(func(fd int) error { effect = possible _, err := eintr(func() (struct{}, error) { return struct{}{}, sync(fd) }) return err @@ -369,11 +378,14 @@ func (s *Service) ReadDir(_ context.Context, a sandboxfs.Attachment, r *sandboxf if err != nil { return nil, err } + if h.tree { + return nil, failure(unix.EBADF, none) + } if h.dir == nil { return nil, failure(unix.ENOTDIR, none) } var resp *sandboxfs.ReadDirResponse - err = use(h.f, errStaleHandle, func(fd int) (err error) { + err = h.use(func(fd int) (err error) { resp, err = h.dir.read(st, fd, r) return err }) @@ -415,6 +427,9 @@ func (s *Service) SetLock(ctx context.Context, a sandboxfs.Attachment, r *sandbo if err != nil { return nil, err } + if h.tree { + return nil, unsupported("tree result operation") + } effect := none for delay := time.Millisecond; ; delay = min(2*delay, 50*time.Millisecond) { if err := ctx.Err(); err != nil { @@ -448,7 +463,7 @@ func (h *handle) tryLock(mode sandboxfs.LockMode) (dropped bool, err error) { h.lockMu.Lock() defer h.lockMu.Unlock() converting := h.flock != 0 && h.flock != mode && mode != sandboxfs.LockUnlock - err = use(h.f, errStaleHandle, func(fd int) error { return unix.Flock(fd, flockOps[mode]|unix.LOCK_NB) }) + err = h.use(func(fd int) error { return unix.Flock(fd, flockOps[mode]|unix.LOCK_NB) }) switch { case err == nil && mode == sandboxfs.LockUnlock: h.flock = 0 diff --git a/apps/sandboxio/internal/fileservice/namespace.go b/apps/sandboxio/internal/fileservice/namespace.go index b73238a61..d2c5a26b7 100644 --- a/apps/sandboxio/internal/fileservice/namespace.go +++ b/apps/sandboxio/internal/fileservice/namespace.go @@ -146,12 +146,22 @@ func (s *Service) GetAttr(_ context.Context, a sandboxfs.Attachment, r *sandboxf if err != nil { return nil, err } - f, _, stale, err := st.target(r.Target) - if err != nil { - return nil, err - } var sb unix.Stat_t - if err := use(f, stale, func(fd int) error { return unix.Fstat(fd, &sb) }); err != nil { + stat := func(fd int) error { return unix.Fstat(fd, &sb) } + if r.Target.Kind == sandboxfs.TargetHandle { + h, handleErr := st.handle(r.Target.Handle) + if handleErr != nil { + return nil, handleErr + } + err = h.use(stat) + } else { + f, _, stale, targetErr := st.target(r.Target) + if targetErr != nil { + return nil, targetErr + } + err = use(f, stale, stat) + } + if err != nil { return nil, failure(err, none) } return &sandboxfs.GetAttrResponse{Attr: st.attr(&sb)}, nil @@ -164,6 +174,15 @@ func (s *Service) SetAttr(_ context.Context, a sandboxfs.Attachment, r *sandboxf if err != nil { return nil, err } + if r.Target.Kind == sandboxfs.TargetHandle { + h, err := st.handle(r.Target.Handle) + if err != nil { + return nil, err + } + if h.tree { + return nil, failure(unix.EROFS, none) + } + } f, typ, stale, err := st.target(r.Target) if err != nil { return nil, err diff --git a/apps/sandboxio/internal/fileservice/service.go b/apps/sandboxio/internal/fileservice/service.go index 978635993..ec6589ca0 100644 --- a/apps/sandboxio/internal/fileservice/service.go +++ b/apps/sandboxio/internal/fileservice/service.go @@ -33,9 +33,15 @@ type Service struct { proc *os.File // /proc/self/fd, for reopening a held descriptor fdinfo *os.File // /proc/self/fdinfo, for mount IDs statx does not report - mu sync.Mutex - atts map[sandboxwire.ID]*state - closed bool + mu sync.Mutex + atts map[sandboxwire.ID]*state + closed bool + closeDone chan struct{} + attachments sync.WaitGroup + + treeMu sync.Mutex + treeCount int + treeBytes uint64 } // New serves the absolute directory root as the export world. oac-sandbox-io @@ -50,9 +56,10 @@ func New(root string) (*Service, error) { } unix.Umask(0) s := &Service{ - instance: sandboxwire.NewID(), - identity: sandboxfs.Identity{UID: uint32(unix.Geteuid()), GID: uint32(unix.Getegid())}, - atts: map[sandboxwire.ID]*state{}, + instance: sandboxwire.NewID(), + identity: sandboxfs.Identity{UID: uint32(unix.Geteuid()), GID: uint32(unix.Getegid())}, + atts: map[sandboxwire.ID]*state{}, + closeDone: make(chan struct{}), } for _, d := range []struct { f **os.File @@ -83,6 +90,8 @@ func New(root string) (*Service, error) { MaxWalkComponents: 256, MaxReadDirBytes: 64 << 10, MaxOpenHandles: 4096, + MaxTreeEntries: 4096, + MaxTreeDataBytes: 32 << 20, AtomicAppend: true, AtomicRename: true, RenameNoReplace: noReplace, @@ -124,18 +133,33 @@ func (s *Service) InstanceID() sandboxwire.ID { return s.instance } // running fail as stale. func (s *Service) Close() error { s.mu.Lock() + if s.closed { + s.mu.Unlock() + <-s.closeDone + return nil + } s.closed = true atts := s.atts s.atts = map[sandboxwire.ID]*state{} s.mu.Unlock() + closing := make(map[*state]bool, len(atts)) for _, st := range atts { - st.close() + closing[st] = st.stop() + } + for st, ownsClose := range closing { + if ownsClose { + st.closeResources() + } else { + <-st.closeDone + } } + s.attachments.Wait() // also joins Detach calls that removed their state for _, f := range []*os.File{s.root, s.proc, s.fdinfo} { if f != nil { f.Close() } } + close(s.closeDone) return nil } @@ -238,9 +262,12 @@ func (s *Service) Attach(_ context.Context, a sandboxfs.Attachment, r *sandboxfs return nil, sandboxfs.NewFailure(sandboxfs.CodeInvalidArgument, sandboxwire.EffectNone, "attachment is already attached") } s.atts[a.ID] = st + s.attachments.Add(1) + st.pending.Add(1) // Attach root acquisition s.mu.Unlock() go s.watch(a, st) root, err := st.addNode(fd, &sb) + st.pending.Done() if err != nil { s.detach(a.ID, st) return nil, failure(err, sandboxwire.EffectNone) @@ -277,9 +304,13 @@ func (s *Service) Detach(_ context.Context, a sandboxfs.Attachment, r *sandboxfs // state is one attachment: its node and handle tables. type state struct { - svc *Service - readOnly bool - done chan struct{} + svc *Service + readOnly bool + done chan struct{} + closeDone chan struct{} + pending sync.WaitGroup + trees sync.WaitGroup + treeReserved bool mu sync.Mutex closed bool @@ -346,8 +377,13 @@ type node struct { } type handle struct { - f *os.File - dir *cursor // set for directory handles + f *os.File + dir *cursor // set for directory handles + tree bool // sealed OpenTree result + closeOnce sync.Once + closeErr error + afterClose func() + useMu sync.RWMutex // joins descriptor users before closing and returning budget writeMu sync.Mutex // holds a write and the O_APPEND mode it sets on f @@ -359,23 +395,41 @@ func newState(s *Service, readOnly bool) *state { // A random generation base makes a NodeRef from another attachment or // incarnation miss instead of naming a live object. return &state{ - svc: s, readOnly: readOnly, done: make(chan struct{}), + svc: s, readOnly: readOnly, done: make(chan struct{}), closeDone: make(chan struct{}), inodes: map[inodeKey]*node{}, handles: map[sandboxfs.HandleID]*handle{}, generation: rand.Uint64() >> 2, } } -// close releases every node and handle; closing a handle releases its locks. -func (st *state) close() { +// stop closes admission and signals running acquisitions before any join. +func (st *state) stop() bool { st.mu.Lock() + defer st.mu.Unlock() if st.closed { - st.mu.Unlock() - return + return false } st.closed = true + close(st.done) + return true +} + +// close releases every node and handle; closing a handle releases its locks. +func (st *state) close() { + if st.stop() { + st.closeResources() + } else { + <-st.closeDone + } +} + +func (st *state) closeResources() { + defer st.svc.attachments.Done() + // Acquisitions observe done and dispose of private descriptors before + // joining. Never wait while holding the state or service mutex. + st.pending.Wait() + st.mu.Lock() nodes, handles := st.nodes, st.handles st.nodes, st.inodes, st.handles = nil, nil, nil - close(st.done) st.mu.Unlock() for _, n := range nodes { if n != nil { @@ -384,9 +438,34 @@ func (st *state) close() { } for _, h := range handles { if h != nil { - h.f.Close() + h.close() } } + // A concurrent Release may already have removed its handle from the map. + st.trees.Wait() + close(st.closeDone) +} + +// close waits for descriptor users, including Read, before returning the +// result's reservation. sync.Once also joins concurrent close callers. +func (h *handle) close() error { + h.closeOnce.Do(func() { + h.useMu.Lock() + defer h.useMu.Unlock() + h.closeErr = h.f.Close() + if h.afterClose != nil { + h.afterClose() + } + }) + return h.closeErr +} + +// use participates in the handle's close fence. os.File.Close alone does +// not wait for RawConn users of blocking descriptors such as regular files. +func (h *handle) use(fn func(int) error) error { + h.useMu.RLock() + defer h.useMu.RUnlock() + return use(h.f, errStaleHandle, fn) } // addNode takes ownership of fd and acquires one reference on its node. @@ -488,11 +567,13 @@ func (st *state) reserve(id sandboxfs.HandleID) error { return sandboxfs.NewFailure(sandboxfs.CodeResourceExhausted, sandboxwire.EffectNone, "too many open handles") } st.handles[id] = nil + st.pending.Add(1) return nil } // unreserve drops the reservation of an acquisition that failed. func (st *state) unreserve(id sandboxfs.HandleID) { + defer st.pending.Done() st.mu.Lock() if !st.closed && st.handles[id] == nil { delete(st.handles, id) @@ -502,13 +583,15 @@ func (st *state) unreserve(id sandboxfs.HandleID) { // publish installs h under its reserved ID. func (st *state) publish(id sandboxfs.HandleID, h *handle) error { + defer st.pending.Done() st.mu.Lock() - defer st.mu.Unlock() if st.closed { - h.f.Close() + st.mu.Unlock() + h.close() return errStaleAttachment() } st.handles[id] = h + st.mu.Unlock() return nil } @@ -542,7 +625,7 @@ func (st *state) release(id sandboxfs.HandleID, dir bool) error { } delete(st.handles, id) st.mu.Unlock() - return h.f.Close() + return h.close() } // attr converts a stat. Ino combines the device with the inode number, as diff --git a/apps/sandboxio/internal/fileservice/tree.go b/apps/sandboxio/internal/fileservice/tree.go new file mode 100644 index 000000000..7cc4d4cae --- /dev/null +++ b/apps/sandboxio/internal/fileservice/tree.go @@ -0,0 +1,332 @@ +//go:build linux + +package fileservice + +import ( + "bytes" + "context" + "errors" + "io" + "os" + "sort" + + "github.com/MiniMax-AI/OpenAgentCore/internal/sandboxfs" + "github.com/MiniMax-AI/OpenAgentCore/internal/sandboxwire" + "golang.org/x/sys/unix" +) + +const ( + maxTreeResults = 4 + maxTreeBackingBytes = 128 << 20 +) + +// reserveTree holds the backing budget through pending acquisition and the +// final descriptor close. This is independent of the number of open handles. +func (st *state) reserveTree(size uint64) (func(), error) { + st.mu.Lock() + defer st.mu.Unlock() + if st.closed { + return nil, errStaleAttachment() + } + s := st.svc + s.treeMu.Lock() + defer s.treeMu.Unlock() + if st.treeReserved || s.treeCount == maxTreeResults || size > maxTreeBackingBytes-s.treeBytes { + return nil, sandboxfs.NewFailure(sandboxfs.CodeResourceExhausted, none, "tree result budget is exhausted") + } + st.treeReserved = true + s.treeCount++ + s.treeBytes += size + st.trees.Add(1) + return func() { + st.mu.Lock() + s.treeMu.Lock() + s.treeCount-- + s.treeBytes -= size + st.treeReserved = false + s.treeMu.Unlock() + st.mu.Unlock() + st.trees.Done() + }, nil +} + +// treeCapture holds source objects directly, without publishing lookup +// references. Enumeration finishes and closes its working directory before +// recursion, so depth does not multiply getdents buffers or working fds. +type treeCapture struct { + st *state + ctx context.Context + req *sandboxfs.OpenTreeRequest + files []*os.File + records []capturedTreeEntry + count uint32 + dataBytes uint64 +} + +type capturedTreeEntry struct { + record sandboxfs.TreeRecord + f *os.File +} + +func (t *treeCapture) check() error { + if err := t.ctx.Err(); err != nil { + return contextFailure(err, none) + } + select { + case <-t.st.done: + return errStaleAttachment() + default: + return nil + } +} + +func (t *treeCapture) close() { + for _, f := range t.files { + f.Close() + } + t.files = nil +} + +// retain takes ownership of fd even when validation fails. +func (t *treeCapture) retain(fd int, name []byte) (capturedTreeEntry, error) { + f := os.NewFile(uintptr(fd), "") + t.files = append(t.files, f) + var sb unix.Stat_t + if err := unix.Fstat(fd, &sb); err != nil { + return capturedTreeEntry{}, err + } + e := capturedTreeEntry{record: sandboxfs.TreeRecord{Name: name, Attr: t.st.attr(&sb)}, f: f} + if t.count >= t.req.MaxEntries { + return capturedTreeEntry{}, unix.EOVERFLOW + } + if err := sandboxfs.ValidateTreeEntry(e.record, t.count == 0, *t.req, t.st.svc.caps); err != nil { + return capturedTreeEntry{}, treeFailure(err) + } + if sb.Mode&sandboxfs.ModeType == sandboxfs.ModeRegular { + if sb.Size < 0 || uint64(sb.Size) > t.req.MaxDataBytes-t.dataBytes { + return capturedTreeEntry{}, unix.EOVERFLOW + } + t.dataBytes += uint64(sb.Size) + } + t.count++ + return e, nil +} + +func (t *treeCapture) walk(e capturedTreeEntry, parent uint32) error { + if err := t.check(); err != nil { + return err + } + index := uint32(len(t.records)) + e.record.Parent = parent + t.records = append(t.records, e) + if e.record.Attr.Mode&sandboxfs.ModeType != sandboxfs.ModeDirectory { + return nil + } + children, err := t.children(e) + if err != nil { + return err + } + for _, child := range children { + if err := t.walk(child, index); err != nil { + return err + } + } + return nil +} + +func (t *treeCapture) children(e capturedTreeEntry) ([]capturedTreeEntry, error) { + fd, err := t.st.svc.reopen(int(e.f.Fd()), sandboxfs.ModeDirectory, unix.O_RDONLY|unix.O_DIRECTORY) + if err != nil { + return nil, err + } + defer unix.Close(fd) + c := cursor{buf: make([]byte, 32<<10)} + children := []capturedTreeEntry{} + names := map[string]bool{} + remaining := t.req.MaxEntries - t.count + for { + if err := t.check(); err != nil { + return nil, err + } + if len(c.rest) == 0 { + n, err := eintr(func() (int, error) { return unix.Getdents(fd, c.buf) }) + if err != nil { + return nil, err + } + if n == 0 { + break + } + c.rest = c.buf[:n] + } + raw, rest, err := c.next() + if err != nil { + return nil, err + } + c.rest = rest + name := string(raw.name) + if name == "." || name == ".." { + continue + } + if names[name] { + return nil, unix.EINVAL + } + if uint32(len(names)) >= remaining { + return nil, unix.EOVERFLOW + } + names[name] = true + held, err := openat(fd, name, unix.O_PATH|unix.O_NOFOLLOW, 0) + if err == unix.ENOENT { + continue + } + if err != nil { + return nil, err + } + child, err := t.retain(held, bytes.Clone(raw.name)) + if err != nil { + return nil, err + } + children = append(children, child) + } + sort.Slice(children, func(i, j int) bool { return bytes.Compare(children[i].record.Name, children[j].record.Name) < 0 }) + return children, nil +} + +// treeReader checks cancellation at every bounded body read. The encoder +// owns record validation and enforces exact observed file sizes. +type treeReader struct { + t *treeCapture + f *os.File +} + +func (r treeReader) Read(p []byte) (int, error) { + if err := r.t.check(); err != nil { + return 0, err + } + return r.f.Read(p) +} + +func (s *Service) OpenTree(ctx context.Context, a sandboxfs.Attachment, r *sandboxfs.OpenTreeRequest) (*sandboxfs.OpenTreeResponse, error) { + st, err := s.enter(a, r) + if err != nil { + return nil, err + } + n, err := st.dir(r.Node) + if err != nil { + return nil, err + } + bound, err := sandboxfs.TreeSizeBound(*r, s.caps) + if err != nil { + return nil, err + } + if err := st.reserve(r.Handle); err != nil { + return nil, err + } + pending := true + defer func() { + if pending { + st.unreserve(r.Handle) + } + }() + releaseBudget, err := st.reserveTree(bound) + if err != nil { + return nil, err + } + ownedBudget := true + defer func() { + if ownedBudget { + releaseBudget() + } + }() + t := &treeCapture{st: st, ctx: ctx, req: r} + defer t.close() + if err := t.check(); err != nil { + return nil, err + } + var root capturedTreeEntry + err = use(n.f, errStaleNode, func(fd int) error { + dup, err := unix.FcntlInt(uintptr(fd), unix.F_DUPFD_CLOEXEC, 0) + if err != nil { + return err + } + root, err = t.retain(dup, nil) + return err + }) + if err != nil { + return nil, failure(err, none) + } + if err := t.walk(root, 0); err != nil { + return nil, failure(err, none) + } + fd, err := unix.MemfdCreate("oac-file-tree", unix.MFD_CLOEXEC|unix.MFD_ALLOW_SEALING) + if err != nil { + return nil, failure(err, none) + } + result := os.NewFile(uintptr(fd), "") + ownedResult := true + defer func() { + if ownedResult { + result.Close() + } + }() + if err := t.encode(result); err != nil { + return nil, treeFailure(err) + } + if err := unix.Fchmod(fd, 0400); err != nil { + return nil, failure(err, none) + } + if _, err := unix.FcntlInt(uintptr(fd), unix.F_ADD_SEALS, unix.F_SEAL_WRITE|unix.F_SEAL_GROW|unix.F_SEAL_SHRINK|unix.F_SEAL_SEAL); err != nil { + return nil, failure(err, none) + } + var sb unix.Stat_t + if err := unix.Fstat(fd, &sb); err != nil { + return nil, failure(err, none) + } + t.close() + if err := t.check(); err != nil { + return nil, err + } + ownedBudget, ownedResult, pending = false, false, false + if err := st.publish(r.Handle, &handle{f: result, tree: true, afterClose: releaseBudget}); err != nil { + return nil, failure(err, none) + } + return &sandboxfs.OpenTreeResponse{Size: uint64(sb.Size)}, nil +} + +// encode reads the objects retained by capture, not their current names. +func (t *treeCapture) encode(w io.Writer) error { + encoder, err := sandboxfs.NewTreeEncoder(w, *t.req, t.st.svc.caps, t.count, t.dataBytes) + if err != nil { + return err + } + for _, e := range t.records { + if err := t.check(); err != nil { + return err + } + var body io.Reader + var source *os.File + if e.record.Attr.Mode&sandboxfs.ModeType == sandboxfs.ModeRegular { + fd, err := t.st.svc.reopen(int(e.f.Fd()), sandboxfs.ModeRegular, unix.O_RDONLY) + if err != nil { + return err + } + source = os.NewFile(uintptr(fd), "") + body = treeReader{t: t, f: source} + } + err := encoder.Write(e.record, body) + if source != nil { + source.Close() + } + if err != nil { + return err + } + } + return encoder.Close() +} + +// Malformed captured data is an invalid tree, not a transport or disk error. +func treeFailure(err error) error { + if errors.Is(err, sandboxwire.ErrMalformed) || errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) { + return failure(unix.EINVAL, none) + } + return failure(err, none) +} diff --git a/apps/sandboxio/internal/fileservice/tree_test.go b/apps/sandboxio/internal/fileservice/tree_test.go new file mode 100644 index 000000000..d90770a5b --- /dev/null +++ b/apps/sandboxio/internal/fileservice/tree_test.go @@ -0,0 +1,625 @@ +//go:build linux + +package fileservice + +import ( + "bytes" + "context" + "crypto/sha256" + "errors" + "fmt" + "io" + "net" + "os" + "path/filepath" + "sync" + "testing" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/internal/sandboxfs" + "github.com/MiniMax-AI/OpenAgentCore/internal/sandboxlink" + "github.com/MiniMax-AI/OpenAgentCore/internal/sandboxwire" + "golang.org/x/sys/unix" +) + +func treeRequest(f *fixture) sandboxfs.OpenTreeRequest { + return sandboxfs.OpenTreeRequest{Handle: f.ids.Next(), Node: f.root, MaxEntries: 1000, MaxDataBytes: 20 << 20, RequireReadOnlyFiles: true} +} + +func treeBudget(t *testing.T, s *Service, count int, size uint64) { + t.Helper() + s.treeMu.Lock() + defer s.treeMu.Unlock() + if s.treeCount != count || s.treeBytes != size { + t.Fatalf("tree budget = %d/%d, want %d/%d", s.treeCount, s.treeBytes, count, size) + } +} + +// Reading through the public client exercises chunk boundaries and the +// ordinary handle path instead of accessing the memfd directly. +type treeTestReader struct { + f *fixture + h sandboxfs.HandleID + off uint64 +} + +func (r *treeTestReader) Read(p []byte) (int, error) { + resp, err := r.f.c.Read(context.Background(), &sandboxfs.ReadRequest{Handle: r.h, Offset: r.off, Size: uint32(min(len(p), sandboxwire.MaxChunk))}) + if err != nil { + return 0, err + } + n := copy(p, resp.Data) + r.off += uint64(n) + if n == 0 { + return 0, io.EOF + } + return n, nil +} + +func decodeTree(t *testing.T, f *fixture, req sandboxfs.OpenTreeRequest, resp *sandboxfs.OpenTreeResponse, visit func(sandboxfs.TreeRecord, io.Reader)) { + t.Helper() + d, err := sandboxfs.NewTreeDecoder(&treeTestReader{f: f, h: req.Handle}, req, f.svc.caps, resp.Size) + if err != nil { + t.Fatal(err) + } + for { + record, body, err := d.Next() + if err == io.EOF { + break + } + if err != nil { + t.Fatal(err) + } + visit(record, body) + } +} + +func TestOpenTreeLargeFileAndResultOperations(t *testing.T) { + f := newFixture(t) + data := bytes.Repeat([]byte("large tree body\n"), 4096) + file, err := os.OpenFile(filepath.Join(f.dir, "large"), os.O_CREATE|os.O_WRONLY, 0400) + if err != nil { + t.Fatal(err) + } + expected := sha256.New() + remaining := 20 << 20 + for remaining > 0 { + chunk := data[:min(remaining, len(data))] + if _, err := file.Write(chunk); err != nil { + t.Fatal(err) + } + expected.Write(chunk) + remaining -= len(chunk) + } + if err := file.Close(); err != nil { + t.Fatal(err) + } + req := treeRequest(f) + resp, err := f.c.OpenTree(context.Background(), &req) + if err != nil { + t.Fatal(err) + } + bound, err := sandboxfs.TreeSizeBound(req, f.svc.caps) + if err != nil { + t.Fatal(err) + } + treeBudget(t, f.svc, 1, bound) + got := sha256.New() + count := 0 + decodeTree(t, f, req, resp, func(record sandboxfs.TreeRecord, body io.Reader) { + count++ + if record.Attr.Mode&sandboxfs.ModeType == sandboxfs.ModeRegular { + if string(record.Name) != "large" || record.Attr.Size != 20<<20 { + t.Fatalf("record: %+v", record) + } + if _, err := io.Copy(got, body); err != nil { + t.Fatal(err) + } + } + }) + if count != 2 || !bytes.Equal(got.Sum(nil), expected.Sum(nil)) { + t.Fatal("large tree body differed") + } + target := sandboxfs.Target{Kind: sandboxfs.TargetHandle, Handle: req.Handle} + attr, err := f.c.GetAttr(context.Background(), &sandboxfs.GetAttrRequest{Target: target}) + if err != nil { + t.Fatal(err) + } + if attr.Attr.Size != resp.Size || attr.Attr.Mode != sandboxfs.ModeRegular|0400 { + t.Fatalf("result attr: %+v", attr.Attr) + } + st := f.svc.atts[f.att.ID] + h, err := st.handle(req.Handle) + if err != nil { + t.Fatal(err) + } + seals, err := unix.FcntlInt(h.f.Fd(), unix.F_GET_SEALS, 0) + if err != nil || seals != unix.F_SEAL_WRITE|unix.F_SEAL_GROW|unix.F_SEAL_SHRINK|unix.F_SEAL_SEAL { + t.Fatalf("seals=%x: %v", seals, err) + } + _, err = f.c.Write(context.Background(), &sandboxfs.WriteRequest{Handle: req.Handle, Data: []byte("x")}) + wantFailure(t, err, sandboxfs.CodeErrno, sandboxfs.ErrnoReadOnlyFilesystem, none) + _, err = f.c.SetAttr(context.Background(), &sandboxfs.SetAttrRequest{Target: target, Set: sandboxfs.AttrMode, Mode: 0600}) + wantFailure(t, err, sandboxfs.CodeErrno, sandboxfs.ErrnoReadOnlyFilesystem, none) + _, err = f.c.Fsync(context.Background(), &sandboxfs.FsyncRequest{Handle: req.Handle}) + wantFailure(t, err, sandboxfs.CodeUnsupported, 0, none) + _, err = f.c.ReadDir(context.Background(), &sandboxfs.ReadDirRequest{Handle: req.Handle, Limit: 4096}) + wantFailure(t, err, sandboxfs.CodeErrno, sandboxfs.ErrnoBadDescriptor, none) + _, err = f.c.ReleaseDir(context.Background(), &sandboxfs.ReleaseDirRequest{Handle: req.Handle}) + wantFailure(t, err, sandboxfs.CodeErrno, sandboxfs.ErrnoBadDescriptor, none) + if _, err = f.c.Flush(context.Background(), &sandboxfs.FlushRequest{Handle: req.Handle}); err != nil { + t.Fatal(err) + } + if _, err = f.c.Release(context.Background(), &sandboxfs.ReleaseRequest{Handle: req.Handle}); err != nil { + t.Fatal(err) + } + treeBudget(t, f.svc, 0, 0) + t.Logf("20 MiB body: sealed result backing=%d bytes; reserved bound=%d bytes; release budget=0", resp.Size, bound) +} + +func TestOpenTreeDeepDirectoryKeepsLinearRecords(t *testing.T) { + f := newFixture(t) + dir, err := unix.Open(f.dir, unix.O_DIRECTORY|unix.O_RDONLY|unix.O_CLOEXEC, 0) + if err != nil { + t.Fatal(err) + } + name := string(bytes.Repeat([]byte("d"), 200)) + const depth = 200 + for range depth { + if err := unix.Mkdirat(dir, name, 0700); err != nil { + t.Fatal(err) + } + next, err := unix.Openat(dir, name, unix.O_DIRECTORY|unix.O_RDONLY|unix.O_CLOEXEC, 0) + if err != nil { + t.Fatal(err) + } + unix.Close(dir) + dir = next + } + fd, err := unix.Openat(dir, "last", unix.O_CREAT|unix.O_WRONLY|unix.O_CLOEXEC, 0400) + unix.Close(dir) + if err != nil { + t.Fatal(err) + } + unix.Close(fd) + req := treeRequest(f) + resp, err := f.c.OpenTree(context.Background(), &req) + if err != nil { + t.Fatal(err) + } + index := uint32(0) + decodeTree(t, f, req, resp, func(record sandboxfs.TreeRecord, body io.Reader) { + if index > 0 && record.Parent != index-1 { + t.Fatalf("parent %d at %d", record.Parent, index) + } + if _, err := io.Copy(io.Discard, body); err != nil { + t.Fatal(err) + } + index++ + }) + if index != depth+2 || resp.Size > uint64(index)*400 { + t.Fatalf("count=%d encoded=%d", index, resp.Size) + } +} + +func TestOpenTreeRejectsInvalidTreesAndReleasesBudget(t *testing.T) { + cases := []struct { + name string + setup func(string) error + entries uint32 + data uint64 + want sandboxfs.Errno + }{ + {"writable", func(p string) error { return os.WriteFile(p, nil, 0600) }, 10, 100, sandboxfs.ErrnoInvalidArgument}, + {"symlink", func(p string) error { return os.Symlink("/etc/passwd", p) }, 10, 100, sandboxfs.ErrnoInvalidArgument}, + {"fifo", func(p string) error { return unix.Mkfifo(p, 0400) }, 10, 100, sandboxfs.ErrnoInvalidArgument}, + {"data-limit", func(p string) error { return os.WriteFile(p, []byte("ab"), 0400) }, 10, 1, sandboxfs.ErrnoOverflow}, + {"entry-limit", func(p string) error { return os.WriteFile(p, nil, 0400) }, 1, 100, sandboxfs.ErrnoOverflow}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + f := newFixture(t) + if err := tc.setup(filepath.Join(f.dir, "entry")); err != nil { + t.Fatal(err) + } + req := treeRequest(f) + req.MaxEntries = tc.entries + req.MaxDataBytes = tc.data + _, err := f.c.OpenTree(context.Background(), &req) + wantFailure(t, err, sandboxfs.CodeErrno, tc.want, none) + treeBudget(t, f.svc, 0, 0) + if f.handles() != 0 { + t.Fatal("failed acquisition left handle") + } + }) + } +} + +func TestOpenTreeBudgetAcrossAttachments(t *testing.T) { + for _, large := range []bool{false, true} { + t.Run(fmt.Sprint(large), func(t *testing.T) { + f := newFixture(t) + data := uint64(20 << 20) + limit := 4 + if large { + data = 32 << 20 + limit = 3 + } + var atts []sandboxfs.Attachment + var handles []sandboxfs.HandleID + var total uint64 + for i := 0; i < limit+1; i++ { + att := attachment(f.svc, sandboxlink.ExportGrant{ID: sandboxfs.WorldExport}) + root, err := f.svc.Attach(context.Background(), att, &sandboxfs.AttachRequest{Export: sandboxfs.WorldExport}) + if err != nil { + t.Fatal(err) + } + req := treeRequest(f) + req.Node = root.Root.Node + req.MaxDataBytes = data + req.MaxEntries = f.svc.caps.MaxTreeEntries + _, err = f.svc.OpenTree(context.Background(), att, &req) + if i == limit { + wantFailure(t, err, sandboxfs.CodeResourceExhausted, 0, none) + break + } + if err != nil { + t.Fatal(err) + } + bound, _ := sandboxfs.TreeSizeBound(req, f.svc.caps) + total += bound + treeBudget(t, f.svc, i+1, total) + duplicate := req + duplicate.Handle = f.ids.Next() + _, err = f.svc.OpenTree(context.Background(), att, &duplicate) + wantFailure(t, err, sandboxfs.CodeResourceExhausted, 0, none) + atts = append(atts, att) + handles = append(handles, req.Handle) + } + for i, att := range atts { + if _, err := f.svc.Release(context.Background(), att, &sandboxfs.ReleaseRequest{Handle: handles[i]}); err != nil { + t.Fatal(err) + } + } + treeBudget(t, f.svc, 0, 0) + }) + } +} + +func TestLostOpenTreeReplyIsReleased(t *testing.T) { + f := newFixture(t) + cc, sc := net.Pipe() + go f.srv.Serve(context.Background(), dropConn{sc, sandboxwire.ResponseType(uint16(sandboxfs.OpOpenTree))}, f.att, binds.Add(1)) + c := sandboxfs.NewClient(cc) + defer c.Close() + req := treeRequest(f) + _, err := c.OpenTree(context.Background(), &req) + fail := wantFailure(t, err, sandboxfs.CodeUnknown, 0, possible) + if !errors.Is(fail, sandboxfs.ErrTransport) { + t.Fatal("expected lost response") + } + bound, _ := sandboxfs.TreeSizeBound(req, f.svc.caps) + treeBudget(t, f.svc, 1, bound) + resumed, _, _ := f.connect(f.srv, f.att) + if _, err := resumed.Release(context.Background(), &sandboxfs.ReleaseRequest{Handle: req.Handle}); err != nil { + t.Fatal(err) + } + treeBudget(t, f.svc, 0, 0) +} + +// gatedTreeContext pauses the first admitted check without modifying product +// code. Closing the attachment must signal done and join this acquisition. +type gatedTreeContext struct { + context.Context + entered chan struct{} + resume chan struct{} + once sync.Once +} + +func (c *gatedTreeContext) Err() error { + c.once.Do(func() { close(c.entered); <-c.resume }) + return c.Context.Err() +} + +func TestOpenTreeCloseJoinsPendingAcquisition(t *testing.T) { + for _, serviceClose := range []bool{false, true} { + t.Run(fmt.Sprint(serviceClose), func(t *testing.T) { + f := newFixture(t) + st := f.svc.atts[f.att.ID] + ctx := &gatedTreeContext{Context: context.Background(), entered: make(chan struct{}), resume: make(chan struct{})} + resume := sync.OnceFunc(func() { close(ctx.resume) }) + defer resume() + req := treeRequest(f) + result := make(chan error, 1) + go func() { _, err := f.svc.OpenTree(ctx, f.att, &req); result <- err }() + <-ctx.entered + bound, _ := sandboxfs.TreeSizeBound(req, f.svc.caps) + treeBudget(t, f.svc, 1, bound) + closed := make(chan error, 1) + go func() { + if serviceClose { + closed <- f.svc.Close() + } else { + _, err := f.svc.Detach(context.Background(), f.att, &sandboxfs.DetachRequest{}) + closed <- err + } + }() + <-st.done + select { + case err := <-closed: + t.Fatalf("close returned before pending acquisition joined: %v", err) + default: + } + treeBudget(t, f.svc, 1, bound) + if err := use(f.svc.proc, errStaleAttachment, func(int) error { return nil }); err != nil { + t.Fatal("proc closed under acquisition") + } + resume() + wantFailure(t, <-result, sandboxfs.CodeStaleAttachment, 0, none) + select { + case err := <-closed: + if err != nil { + t.Fatal(err) + } + case <-time.After(5 * time.Second): + t.Fatal("close did not join") + } + treeBudget(t, f.svc, 0, 0) + }) + } +} + +func TestOpenTreeCloseWaitsForDescriptorRead(t *testing.T) { + for _, operation := range []string{"release", "detach", "service"} { + t.Run(operation, func(t *testing.T) { + f := newFixture(t) + req := treeRequest(f) + if _, err := f.svc.OpenTree(context.Background(), f.att, &req); err != nil { + t.Fatal(err) + } + st := f.svc.atts[f.att.ID] + h, err := st.handle(req.Handle) + if err != nil { + t.Fatal(err) + } + entered, resume := make(chan struct{}), make(chan struct{}) + unblock := sync.OnceFunc(func() { close(resume) }) + defer unblock() + reading := make(chan error, 1) + go func() { + reading <- h.use(func(fd int) error { + close(entered) + <-resume + buf := make([]byte, 12) + _, err := unix.Pread(fd, buf, 0) + return err + }) + }() + <-entered + closed := make(chan error, 1) + go func() { + switch operation { + case "release": + _, err := f.svc.Release(context.Background(), f.att, &sandboxfs.ReleaseRequest{Handle: req.Handle}) + closed <- err + case "detach": + _, err := f.svc.Detach(context.Background(), f.att, &sandboxfs.DetachRequest{}) + closed <- err + default: + closed <- f.svc.Close() + } + }() + // Wait until close admission is observable, without relying on sleeps. + deadline := time.Now().Add(5 * time.Second) + for { + st.mu.Lock() + _, present := st.handles[req.Handle] + st.mu.Unlock() + if !present { + break + } + if time.Now().After(deadline) { + t.Fatal("close not entered") + } + time.Sleep(time.Millisecond) + } + bound, _ := sandboxfs.TreeSizeBound(req, f.svc.caps) + treeBudget(t, f.svc, 1, bound) + select { + case err := <-closed: + t.Fatalf("close returned during descriptor use: %v", err) + default: + } + unblock() + if err := <-reading; err != nil { + t.Fatal(err) + } + if err := <-closed; err != nil { + t.Fatal(err) + } + treeBudget(t, f.svc, 0, 0) + }) + } +} + +func TestOpenTreeCancellationLeavesNoResources(t *testing.T) { + f := newFixture(t) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + req := treeRequest(f) + _, err := f.svc.OpenTree(ctx, f.att, &req) + wantFailure(t, err, sandboxfs.CodeCancelled, 0, none) + treeBudget(t, f.svc, 0, 0) + if f.handles() != 0 { + t.Fatal("cancelled acquisition left handle") + } +} + +func TestOpenTreeHeldRootSurvivesRenameAndReplacement(t *testing.T) { + f := newFixture(t) + old := filepath.Join(f.dir, "root") + if err := os.Mkdir(old, 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(old, "original"), []byte("original body"), 0400); err != nil { + t.Fatal(err) + } + root := f.lookup(f.root, "root").Node + if err := os.Rename(old, old+"-moved"); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(old, 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(old, "replacement"), []byte("replacement body"), 0400); err != nil { + t.Fatal(err) + } + req := treeRequest(f) + req.Node = root + resp, err := f.c.OpenTree(context.Background(), &req) + if err != nil { + t.Fatal(err) + } + files := 0 + decodeTree(t, f, req, resp, func(record sandboxfs.TreeRecord, body io.Reader) { + if record.Attr.Mode&sandboxfs.ModeType == sandboxfs.ModeRegular { + data, err := io.ReadAll(body) + if err != nil { + t.Fatal(err) + } + if string(record.Name) != "original" || string(data) != "original body" { + t.Fatalf("read replacement: %q %q", record.Name, data) + } + files++ + } + }) + if files != 1 { + t.Fatalf("files=%d", files) + } +} + +// Separate capture and encoding deterministically schedules filesystem +// mutations between them, without timing assumptions or production hooks. +func captureTreeForTest(t *testing.T, f *fixture, req *sandboxfs.OpenTreeRequest) *treeCapture { + t.Helper() + capture := &treeCapture{st: f.svc.atts[f.att.ID], ctx: context.Background(), req: req} + t.Cleanup(capture.close) + fd, err := unix.Open(f.dir, unix.O_PATH|unix.O_DIRECTORY|unix.O_CLOEXEC, 0) + if err != nil { + t.Fatal(err) + } + root, err := capture.retain(fd, nil) + if err != nil { + t.Fatal(err) + } + if err := capture.walk(root, 0); err != nil { + t.Fatal(err) + } + return capture +} + +func TestOpenTreeCapturedFileIdentityAndSizeChanges(t *testing.T) { + for _, operation := range []string{"rename", "unlink", "replacement", "shrink", "grow"} { + t.Run(operation, func(t *testing.T) { + f := newFixture(t) + path := filepath.Join(f.dir, "entry") + if err := os.WriteFile(path, []byte("original"), 0400); err != nil { + t.Fatal(err) + } + req := treeRequest(f) + capture := captureTreeForTest(t, f, &req) + switch operation { + case "rename": + if err := os.Rename(path, path+"-moved"); err != nil { + t.Fatal(err) + } + case "unlink": + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + case "replacement": + if err := os.Rename(path, path+"-moved"); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("replaced"), 0400); err != nil { + t.Fatal(err) + } + case "shrink", "grow": + if err := os.Chmod(path, 0600); err != nil { + t.Fatal(err) + } + data := []byte("x") + if operation == "grow" { + data = []byte("larger than original") + } + if err := os.WriteFile(path, data, 0400); err != nil { + t.Fatal(err) + } + } + var encoded bytes.Buffer + err := capture.encode(&encoded) + if operation == "shrink" || operation == "grow" { + wantFailure(t, treeFailure(err), sandboxfs.CodeErrno, sandboxfs.ErrnoInvalidArgument, none) + return + } + if err != nil { + t.Fatal(err) + } + decoder, err := sandboxfs.NewTreeDecoder(bytes.NewReader(encoded.Bytes()), req, f.svc.caps, uint64(encoded.Len())) + if err != nil { + t.Fatal(err) + } + if _, _, err := decoder.Next(); err != nil { + t.Fatal(err) + } + record, body, err := decoder.Next() + if err != nil { + t.Fatal(err) + } + data, err := io.ReadAll(body) + if err != nil { + t.Fatal(err) + } + if string(record.Name) != "entry" || string(data) != "original" { + t.Fatalf("changed object: %q %q", record.Name, data) + } + if _, _, err := decoder.Next(); err != io.EOF { + t.Fatalf("end=%v", err) + } + }) + } +} + +func TestOpenTreeEnforcesProcessPermissions(t *testing.T) { + // Root's native DAC override is intentional; check identical permissions + // against a normal OS read, including that override if this test runs as root. + f := newFixture(t) + path := filepath.Join(f.dir, "entry") + if err := os.WriteFile(path, []byte("private"), 0000); err != nil { + t.Fatal(err) + } + defer os.Chmod(path, 0600) + direct, readErr := os.ReadFile(path) + req := treeRequest(f) + resp, err := f.c.OpenTree(context.Background(), &req) + if readErr != nil { + if !errors.Is(readErr, os.ErrPermission) { + t.Fatal(readErr) + } + wantFailure(t, err, sandboxfs.CodeErrno, sandboxfs.ErrnoPermissionDenied, none) + treeBudget(t, f.svc, 0, 0) + return + } + if err != nil { + t.Fatal(err) + } + decodeTree(t, f, req, resp, func(record sandboxfs.TreeRecord, body io.Reader) { + data, err := io.ReadAll(body) + if err != nil { + t.Fatal(err) + } + if record.Attr.Mode&sandboxfs.ModeType == sandboxfs.ModeRegular && !bytes.Equal(data, direct) { + t.Fatal("OS read differs") + } + }) +} diff --git a/docs/file-access-protocol.md b/docs/file-access-protocol.md index b4424b639..6f8a66d5b 100644 --- a/docs/file-access-protocol.md +++ b/docs/file-access-protocol.md @@ -15,13 +15,13 @@ The File access protocol is how a Runtime reads and changes the files of a sandb ## Implement a client -The Go client is `sandboxfs.NewClient(stream)`. It has one method per operation, is safe for concurrent use, and returns a `*sandboxfs.Failure` for every failure. Its methods map one to one onto the go-fuse node operations, so a FUSE frontend turns each kernel request into one call. +The Go client is `sandboxfs.NewClient(stream)`. It has one method per operation, is safe for concurrent use, and returns a `*sandboxfs.Failure` for every failure. Its node methods map one to one onto the go-fuse operations. `OpenTree` also lets Environment validation read bounded directory trees through a single acquired handle. 1. Call `Describe`. Keep `ServerInstanceID`, and check each request against `Capabilities` before sending it; the service rejects anything the capabilities do not declare. 2. `Attach` an export and keep the root `NodeRef`. 3. `Lookup`, `Walk`, `Create`, `Mkdir`, `Symlink`, `Link` and `ReadDir` with `WithAttrs` each acquire one reference on every node they return. Release references with `Forget` when the kernel forgets them. 4. `Walk` stops after a symlink. Resolve the link yourself, relative to the view, with `Readlink` and further walks. -5. `Open`, `Create` and `OpenDir` open a handle under a [handle ID](#handles) the client chooses. Keep one `sandboxfs.HandleIDs` per attachment, across all of its streams, and take each ID from it. Send `Flush` on each close of a descriptor for the handle, and `Release` or `ReleaseDir` when the last one closes. +5. `Open`, `Create`, `OpenDir` and `OpenTree` open a handle under a [handle ID](#handles) the client chooses. Keep one `sandboxfs.HandleIDs` per attachment, across all of its streams, and take each ID from it. Send `Flush` on each close of a descriptor for the handle, and `Release` or `ReleaseDir` when the last one closes. 6. Set `Append` on each `Write` made while the descriptor is in append mode. Append is a property of the write, not of the handle. 7. Read the `Effect` of every failure. After `EffectPossible`, the request may have taken effect: never replay a mutation automatically. Report the failure, or inspect the state with `GetAttr` or `Lookup` first. A failure with a [retryable](#failures) code and `EffectNone` may be resent unchanged. 8. Clean up an acquisition whose outcome is unknown, because its reply was lost or its call was cancelled, with `Release` or `ReleaseDir` of its ID. After a stream fails, resume on a new stream of the same attachment, which the service serves only after the failed stream's requests have finished, and clean up there: an uncertain `Attach` with `Detach`, and each uncertain acquisition with `Release` or `ReleaseDir`. [Handles](#handles) says what the cleanup proves. @@ -42,7 +42,7 @@ Implement `sandboxfs.Service`, create one `sandboxfs.NewServer(service)`, and se - ends the stream on a framing violation: an unknown tag, a frame that is not a request, or a RequestID that does not increase; - holds up to `sandboxfs.MaxInFlight` (256) requests, each from admission until its response is written, and answers any more with `ResourceExhausted` and `EffectNone`, so a `CancelRequest` arrives while the client reads responses; - answers `CancelRequest` itself by cancelling the target's context; -- refuses an `Open`, `Create` or `OpenDir` whose handle ID another acquisition on the stream is still using, with `InvalidArgument` and `EffectNone`, and runs a `Release` or `ReleaseDir` of an ID only after the running acquisition of that ID has finished. Together with the succession fence, a service never runs two acquisitions of one ID at once, or a release concurrently with the acquisition of its ID; +- refuses an `Open`, `Create`, `OpenDir` or `OpenTree` whose handle ID another acquisition on the stream is still using, with `InvalidArgument` and `EffectNone`, and runs a `Release` or `ReleaseDir` of an ID only after the running acquisition of that ID has finished. Together with the succession fence, a service never runs two acquisitions of one ID at once, or a release concurrently with the acquisition of its ID; - returns a method's `*Failure` as the typed failure, and reports any other error, or a response that fails validation, as `Unknown` with `EffectPossible`; - cancels every request's context when the stream ends. @@ -50,7 +50,7 @@ A service must: - generate a new `ServerInstanceID` whenever it loses its node and handle tables, and answer a request whose `Attachment.ServerInstanceID` is not its own with `InstanceChanged`; - answer `StaleAttachment` while the attachment is not attached or after its lease ends, and `StaleNode` or `StaleHandle` for a reference or handle the attachment does not hold. A node ID is reused only with a new generation; -- reserve the handle ID of an `Open`, `Create` or `OpenDir` atomically before any file-system effect, as [Handles](#handles) describes, and publish every state a request creates before its method returns; +- reserve the handle ID of an `Open`, `Create`, `OpenDir` or `OpenTree` atomically before any file-system effect, as [Handles](#handles) describes, and publish every state a request creates before its method returns; - call `Capabilities.Admit(request, readOnly)` before running a request and return the failure it reports. Limits that depend on service state, such as `MaxOpenHandles`, stay with the service; - advertise only what it enforces, and advertise locks only when they interoperate with native processes in the sandbox; - act as its own process identity, never as an identity a request supplies, and apply requested permission bits exactly; @@ -68,13 +68,13 @@ A service must: - `Rename` uses `renameat2`. The service declares `RenameNoReplace` and `RenameExchange` only when a probe at start succeeds. - `LockFlock` locks the handle's descriptor, so it interoperates with native `flock`. `POSIXLocks` is false, and `GetLock` and `LockPOSIX` return `Unsupported`. - `ReadDir` cookies are the kernel's directory offsets. `Attr.Ino` combines the device and the inode number as go-fuse's loopback does. -- It declares `MaxNameBytes` 255, `MaxPathBytes` 4095, `MaxReadBytes` and `MaxWriteBytes` 64 KiB, `MaxWalkComponents` 256, `MaxReadDirBytes` 64 KiB and `MaxOpenHandles` 4096, and every flag except `ReadOnly` and `POSIXLocks`, with the rename modes as probed. +- It declares `MaxNameBytes` 255, `MaxPathBytes` 4095, `MaxReadBytes` and `MaxWriteBytes` 64 KiB, `MaxWalkComponents` 256, `MaxReadDirBytes` 64 KiB, `MaxOpenHandles` 4096, `MaxTreeEntries` 4096 and `MaxTreeDataBytes` 32 MiB, and every flag except `ReadOnly` and `POSIXLocks`, with the rename modes as probed. ## Reference ### Messages -Requests use tags 1 to 30; the response to tag `t` uses `t | 0x8000`. +File version 3 is matched exactly at Link binding. Requests use tags 1 to 31; the response to tag `t` uses `t | 0x8000`. | Tag | Request | Fields | Response | Meaning | | --- | --- | --- | --- | --- | @@ -108,6 +108,7 @@ Requests use tags 1 to 30; the response to tag `t` uses `t | 0x8000`. | 28 | `GetLock` | `Handle`, `Owner`, `Lock` | optional `Conflict` | A POSIX lock that would conflict | | 29 | `SetLock` | `Handle`, `Kind`, `Owner`, `Lock`, `Wait` | – | Acquire, convert or release a lock | | 30 | `CancelRequest` | `Target` (a RequestID) | – | Ask to cancel an outstanding request | +| 31 | `OpenTree` | `Handle`, `Node`, `MaxEntries`, `MaxDataBytes`, `RequireReadOnlyFiles` | `Size` | Acquire a bounded directory-tree result | A response payload begins with a uint16 result: 1 for success, followed by the response's fields, or 2 for failure, followed by a [`Failure`](#failures). Payloads list their fields in this order: @@ -157,9 +158,11 @@ GetLock Handle u64, Owner u64, Lock GetLockResponse Conflict optional Lock SetLock Handle u64, Kind enum, Owner u64, Lock, Wait bool; response (no fields) CancelRequest Target u64; response (no fields) +OpenTree Handle u64, Node NodeRef, MaxEntries u32, MaxDataBytes u64, RequireReadOnlyFiles bool +OpenTreeResponse Size u64 ``` -[`testdata`](https://github.com/MiniMax-AI/OpenAgentCore/tree/main/internal/sandboxfs/testdata) holds annotated golden frames of `Describe`, `Walk`, `Create`, an append `Write`, a short `Write`, `ReadDir` with a cookie, `Rename` and a failure. +[`testdata`](https://github.com/MiniMax-AI/OpenAgentCore/tree/main/internal/sandboxfs/testdata) holds annotated golden frames of `Describe`, `OpenTree`, its tree result, `Walk`, `Create`, an append `Write`, a short `Write`, `ReadDir` with a cookie, `Rename` and a failure. ### Shared types @@ -196,6 +199,8 @@ Lock Mode enum (LockRead = 1, LockWrite = 2, LockUnlock = 3), Start u64 | `MaxWalkComponents` | u32 | Most `Walk` names, 1 to 1024 | | `MaxReadDirBytes` | u32 | Largest `ReadDir` limit, 1 to 256 KiB | | `MaxOpenHandles` | u32 | Most open handles per attachment, at least 1 | +| `MaxTreeEntries` | u32 | Largest `OpenTree` entry limit, including the root; zero together with `MaxTreeDataBytes` means unsupported | +| `MaxTreeDataBytes` | u64 | Largest `OpenTree` regular-file byte limit; the maximum encoded result must fit in 2^63−1 bytes | | `ReadOnly` | bool | The service accepts only read-only attachments | | `AtomicAppend` | bool | `Write` supports `Append`, and appends from several handles never interleave within a write | | `AtomicRename` | bool | `RenameReplace` replaces the destination atomically | @@ -207,7 +212,7 @@ Lock Mode enum (LockRead = 1, LockWrite = 2, LockUnlock = 3), Start u64 | `Flock` | bool | `SetLock` supports `LockFlock` | | `POSIXLocks` | bool | `GetLock` and `SetLock` support `LockPOSIX` | -A writable service, one without `ReadOnly`, declares `AtomicAppend`, `AtomicRename`, `HardLinks` and `Symlinks`. A request beyond a declared limit fails with `InvalidArgument`, or `Errno` `NameTooLong` for a name or target, and a request for an undeclared feature fails with `Unsupported`. +A writable service, one without `ReadOnly`, declares `AtomicAppend`, `AtomicRename`, `HardLinks` and `Symlinks`. An `OpenTree` request beyond its declared combination fails with `Unsupported`. Other requests beyond a declared limit fail with `InvalidArgument`, or `Errno` `NameTooLong` for a name or target, and a request for an undeclared feature fails with `Unsupported`. ### Attach @@ -255,7 +260,7 @@ Unknown bits are rejected, `AttrAtime` excludes `AttrAtimeNow` and `AttrMtime` e ### Handles -- The client chooses the `HandleID` of each `Open`, `Create` and `OpenDir`: nonzero, and never used before in the attachment, on any of its streams. `sandboxfs.HandleIDs` allocates IDs in increasing order. +- The client chooses the `HandleID` of each `Open`, `Create`, `OpenDir` and `OpenTree`: nonzero, and never used before in the attachment, on any of its streams. `sandboxfs.HandleIDs` allocates IDs in increasing order. - The service reserves the ID before the request has any file-system effect. A reserved ID counts toward `MaxOpenHandles` while its acquisition runs. An acquisition whose ID is reserved or open fails with `InvalidArgument` and `EffectNone`. - Other requests that name a reserved ID fail with `StaleHandle`, except `Release` and `ReleaseDir`: the server runs them only after the acquisition of that ID has finished, so they close the handle it opened. - `Release` or `ReleaseDir` of an ID settles an acquisition whose outcome is unknown, whether its reply was lost with the stream or its call was cancelled. Success or `StaleHandle` proves that no handle with that ID remains. It does not prove that the acquisition changed nothing: a `Create` may have created its file, and a truncating `Open` may have truncated it. @@ -274,6 +279,20 @@ Succession follows Link's bind order, not the order in which streams reach the s - With `WithAttrs`, each returned entry carries an `Entry` with one lookup reference. - `End` is true when no entries remain, and a page without entries always has it set. No name or cookie repeats within a page. `ReadDir` promises no snapshot of a directory that changes while it is read. +### Bounded tree reads + +`OpenTree` reads a directory identified by an authorized `NodeRef`. `MaxEntries` is nonzero and includes the root; `MaxDataBytes` sums regular-file bytes and may be zero. Both must fit the declared capabilities. `RequireReadOnlyFiles` rejects regular files with any write permission bit. The service applies the same attachment, lease, export and OS permission checks as its other reads. It accepts only directories and regular files, never follows symlinks, and returns no new node references. The caller retains its own validation and business limits. + +The service enumerates and retains each object's identity and attributes before reading content from those same objects. Renaming, unlinking or replacing a path never redirects a captured object to another inode. Every file must yield exactly its captured size, with no extra byte. Directory enumeration can observe concurrent changes; this operation is not an atomic filesystem snapshot and does not detect same-size concurrent writes. A failure publishes no partial result. Exceeding the entry or data bound returns `Errno` `Overflow`; invalid types, write bits under `RequireReadOnlyFiles`, duplicate names or changed file lengths return `Errno` `InvalidArgument`. Other OS failures retain their normal typed mapping. + +The immutable result is read with existing offset-based `Read` calls and closed with `Release`. `OpenTreeResponse.Size` is its exact encoded length. The result consists of `Count u32, DataBytes u64`, followed by exactly `Count` records of `ParentIndex u32, Name bytes, Attr`, and `Attr.Size` raw body bytes only for regular files. Record 0 is a directory with parent 0 and empty name. Every other name is one valid component; its parent is an earlier directory still on the active ancestor stack. Records are depth first, with each directory's children in strictly increasing byte-name order. Returning to an already left subtree is invalid. Directories retain their actual attributes but have no body. The sum of all regular-file sizes equals `DataBytes`. The maximum encoded length is `12 + MaxEntries × (96 + MaxNameBytes) + MaxDataBytes`, calculated without overflow; no trailing data is permitted. This stream can exceed the frame limit and split any primitive across `Read` chunks. `NewTreeEncoder` and `NewTreeDecoder` in the authored protocol validate the same rules incrementally, without a second complete encoded-body buffer; a decoder body must be consumed before its next record. + +A result handle supports `Read`, `Release` and `GetAttr(TargetHandle)`; the latter describes the regular, mode-0400 encoded result file, not the source directory. `Flush` succeeds without changing state. `Write` and `SetAttr(TargetHandle)` return `Errno` `ReadOnlyFilesystem`; `Fsync`, `GetLock` and `SetLock` return `Unsupported`; `ReadDir` and `ReleaseDir` return `Errno` `BadDescriptor`. It has no addressable result `NodeRef`. + +`OpenTree` is an acquisition with the same ID reservation, cancellation and release fence as `Open`. Cancelling a sent request can leave a handle and therefore reports `EffectPossible`; settle it with `Release` using an independent cleanup context. Construction checks cancellation while enumerating and reading. Failure cleanup closes source and result descriptors before returning resources; successful publication first closes source descriptors. Detach, lease end and service shutdown stop admission, cancel and join pending acquisitions, and close their results. A slot or byte reservation remains held until the backing descriptor actually closes, including reads still using it. + +The Linux service uses one sealed anonymous memory file (`memfd`) per result. It reserves the maximum encoded size before enumeration: at most four pending or retained results per service, one per attachment, and 128 MiB of reserved result backing in total, as well as the existing `MaxOpenHandles` limit. Admission beyond any budget returns `ResourceExhausted` with `EffectNone`, without waiting or replaying the request. The memory file is sealed against writing, growing and shrinking before publication; unsupported creation or sealing fails without an alternate storage path. These bounds cover result backing, not total RSS. Incremental enumeration limits retained objects and names by `MaxEntries` and rejects duplicate names; it closes each enumeration descriptor before descending. A single chunk buffer suffices for content transfer. No named temporary files, persistent cache or application-specific format is involved. + ### Rename `Rename` takes exactly one mode: `RenameReplace` (1) replaces an existing destination, `RenameNoReplace` (2) fails with `Errno` `Exists` when the destination exists, and `RenameExchange` (3) swaps two existing entries. @@ -304,7 +323,7 @@ Failure | 5 | `InstanceChanged` | The stream is bound to another service incarnation | | 6 | `StaleNode` | The attachment holds no such `NodeRef` | | 7 | `StaleHandle` | The attachment has no such open handle | -| 8 | `ResourceExhausted` | The stream holds `MaxInFlight` requests, or the attachment has `MaxOpenHandles` handles, counting reserved IDs | +| 8 | `ResourceExhausted` | The stream holds `MaxInFlight` requests, the attachment has `MaxOpenHandles` handles including reserved IDs, or a bounded tree-result budget is exhausted | | 9 | `Cancelled` | The request was cancelled | | 10 | `DeadlineExceeded` | The caller's deadline passed | | 11 | `Errno` | A file-system call failed; `Errno` says how | diff --git a/docs/zh/file-access-protocol.md b/docs/zh/file-access-protocol.md index e5368a0f5..124a39ca3 100644 --- a/docs/zh/file-access-protocol.md +++ b/docs/zh/file-access-protocol.md @@ -1,7 +1,7 @@ --- title: "文件访问协议" source: docs/file-access-protocol.md -source_hash: 848d891def538f4a4dc78448089348f470c45a7e55ab07eb96f07a9f727e6fb4 +source_hash: 8588b9605eb829b158958a61aaeabb93f583563889d92ad27b9257b946de90ce --- 文件访问协议定义 Runtime 如何读取和修改沙箱中的文件。沙箱内的 Sandbox I/O 服务提供该协议,Runtime 是其客户端。它是一个 node 与 handle 协议,形态仿照 FUSE 低层操作:lookup 获取 node 引用,open 在客户端选择的 ID 下创建 handle,读写携带偏移量,目录读取从 cookie 处继续,锁与沙箱自身的进程协同生效。第 1 阶段仅提供 [Uncached](#uncached-profile) profile,没有变更 stream。 @@ -17,13 +17,13 @@ source_hash: 848d891def538f4a4dc78448089348f470c45a7e55ab07eb96f07a9f727e6fb4 ## 实现客户端 {#implement-a-client} -Go 客户端为 `sandboxfs.NewClient(stream)`。它为每个操作提供一个方法,可安全并发使用,并对每次失败返回 `*sandboxfs.Failure`。其方法与 go-fuse node 操作一一对应,因此 FUSE 前端将每个内核请求转换为一次调用。 +Go 客户端为 `sandboxfs.NewClient(stream)`。它为每个操作提供一个方法,可安全并发使用,并对每次失败返回 `*sandboxfs.Failure`。其 node 方法与 go-fuse 操作一一对应。`OpenTree` 还让 Environment 验证通过单个获取的 handle 读取有界目录树。 1. 调用 `Describe`。保存 `ServerInstanceID`,并在发送每个请求前对照 `Capabilities` 检查;服务拒绝 capabilities 未声明的任何内容。 2. `Attach` 一个 export,并保存根 `NodeRef`。 3. `Lookup`、`Walk`、`Create`、`Mkdir`、`Symlink`、`Link` 以及带 `WithAttrs` 的 `ReadDir` 各自对返回的每个 node 获取一个引用。内核 forget 引用时,用 `Forget` 释放它们。 4. `Walk` 在 symlink 之后停止。客户端自行相对于视图解析链接:使用 `Readlink` 和后续 walk。 -5. `Open`、`Create` 和 `OpenDir` 在客户端选择的 [handle ID](#handles) 下打开 handle。每个 attachment 在其所有 stream 间共用一个 `sandboxfs.HandleIDs`,并从中获取每个 ID。handle 的每个描述符关闭时发送 `Flush`,最后一个描述符关闭时发送 `Release` 或 `ReleaseDir`。 +5. `Open`、`Create`、`OpenDir` 和 `OpenTree` 在客户端选择的 [handle ID](#handles) 下打开 handle。每个 attachment 在其所有 stream 间共用一个 `sandboxfs.HandleIDs`,并从中获取每个 ID。handle 的每个描述符关闭时发送 `Flush`,最后一个描述符关闭时发送 `Release` 或 `ReleaseDir`。 6. 描述符处于 append 模式时,在每次 `Write` 上设置 `Append`。Append 是写入的属性,不是 handle 的属性。 7. 读取每次失败的 `Effect`。出现 `EffectPossible` 后,请求可能已经生效:绝不自动重放 mutation。报告失败,或先用 `GetAttr` 或 `Lookup` 检查状态。带[可重试](#failures) code 和 `EffectNone` 的失败可以原样重发。 8. 回复丢失或调用被取消而导致结果未知的获取操作,用对其 ID 的 `Release` 或 `ReleaseDir` 清理。stream 失败后,在同一 attachment 的新 stream 上恢复(服务仅在失败 stream 的请求结束后才为其提供服务),并在那里清理:不确定的 `Attach` 用 `Detach`,每个不确定的获取操作用 `Release` 或 `ReleaseDir`。[Handle](#handles) 说明清理能证明什么。 @@ -44,7 +44,7 @@ stream 失败时,每个进行中的请求以 `Unknown` 和 `EffectPossible` - 遇到分帧违规时结束 stream:未知 tag、不是请求的 frame,或不递增的 RequestID; - 最多持有 `sandboxfs.MaxInFlight`(256)个请求,每个从准入起持有到其响应写出,超出的请求回复 `ResourceExhausted` 和 `EffectNone`,因此客户端读取响应期间 `CancelRequest` 仍能送达; - 自行处理 `CancelRequest`,取消目标请求的 context; -- 拒绝 handle ID 仍被该 stream 上另一获取操作使用的 `Open`、`Create` 或 `OpenDir`,回复 `InvalidArgument` 和 `EffectNone`;对某 ID 的 `Release` 或 `ReleaseDir` 仅在该 ID 正在运行的获取操作结束后才运行。结合接替 fence,服务绝不会同时运行同一 ID 的两个获取操作,也不会在某 ID 的获取操作进行时并发运行其释放; +- 拒绝 handle ID 仍被该 stream 上另一获取操作使用的 `Open`、`Create`、`OpenDir` 或 `OpenTree`,回复 `InvalidArgument` 和 `EffectNone`;对某 ID 的 `Release` 或 `ReleaseDir` 仅在该 ID 正在运行的获取操作结束后才运行。结合接替 fence,服务绝不会同时运行同一 ID 的两个获取操作,也不会在某 ID 的获取操作进行时并发运行其释放; - 将方法返回的 `*Failure` 作为类型化失败返回,并把其他任何错误或未通过验证的响应报告为 `Unknown` 加 `EffectPossible`; - stream 结束时取消每个请求的 context。 @@ -52,7 +52,7 @@ stream 失败时,每个进行中的请求以 `Unknown` 和 `EffectPossible` - 每当丢失 node 表和 handle 表时生成新的 `ServerInstanceID`,并对 `Attachment.ServerInstanceID` 不是自身的请求回复 `InstanceChanged`; - attachment 未挂接或其 lease 结束后回复 `StaleAttachment`,对 attachment 未持有的引用或 handle 回复 `StaleNode` 或 `StaleHandle`。node ID 仅在使用新 generation 时复用; -- 按 [Handle](#handles) 所述,在产生任何文件系统作用之前原子地预留 `Open`、`Create` 或 `OpenDir` 的 handle ID,并在方法返回前发布请求创建的所有状态; +- 按 [Handle](#handles) 所述,在产生任何文件系统作用之前原子地预留 `Open`、`Create`、`OpenDir` 或 `OpenTree` 的 handle ID,并在方法返回前发布请求创建的所有状态; - 运行请求前调用 `Capabilities.Admit(request, readOnly)`,并返回其报告的失败。依赖服务状态的限制(如 `MaxOpenHandles`)由服务负责; - 只声明自己强制执行的内容,并且只在锁与沙箱内原生进程互通时声明锁; - 以自身的进程身份运行,绝不使用请求提供的身份,并精确应用请求的权限位; @@ -70,13 +70,13 @@ stream 失败时,每个进行中的请求以 `Unknown` 和 `EffectPossible` - `Rename` 使用 `renameat2`。仅当启动时的探测成功,服务才声明 `RenameNoReplace` 和 `RenameExchange`。 - `LockFlock` 锁定 handle 的描述符,因此与原生 `flock` 互通。`POSIXLocks` 为 false,`GetLock` 和 `LockPOSIX` 返回 `Unsupported`。 - `ReadDir` cookie 是内核的目录偏移。`Attr.Ino` 按 go-fuse loopback 的方式组合设备号和 inode 号。 -- 它声明 `MaxNameBytes` 255、`MaxPathBytes` 4095、`MaxReadBytes` 和 `MaxWriteBytes` 64 KiB、`MaxWalkComponents` 256、`MaxReadDirBytes` 64 KiB 和 `MaxOpenHandles` 4096,以及除 `ReadOnly` 和 `POSIXLocks` 外的所有标志,rename 模式按探测结果声明。 +- 它声明 `MaxNameBytes` 255、`MaxPathBytes` 4095、`MaxReadBytes` 和 `MaxWriteBytes` 64 KiB、`MaxWalkComponents` 256、`MaxReadDirBytes` 64 KiB、`MaxOpenHandles` 4096、`MaxTreeEntries` 4096 和 `MaxTreeDataBytes` 32 MiB,以及除 `ReadOnly` 和 `POSIXLocks` 外的所有标志,rename 模式按探测结果声明。 ## 参考 {#reference} ### 消息 {#messages} -请求使用 tag 1 到 30;tag `t` 的响应使用 `t | 0x8000`。 +File 版本 3 在 Link 绑定时精确匹配。请求使用 tag 1 到 31;tag `t` 的响应使用 `t | 0x8000`。 | Tag | 请求 | 字段 | 响应 | 含义 | | --- | --- | --- | --- | --- | @@ -110,6 +110,7 @@ stream 失败时,每个进行中的请求以 `Unknown` 和 `EffectPossible` | 28 | `GetLock` | `Handle`, `Owner`, `Lock` | 可选 `Conflict` | 会产生冲突的 POSIX 锁 | | 29 | `SetLock` | `Handle`, `Kind`, `Owner`, `Lock`, `Wait` | – | 获取、转换或释放锁 | | 30 | `CancelRequest` | `Target`(一个 RequestID) | – | 请求取消一个未完成的请求 | +| 31 | `OpenTree` | `Handle`、`Node`、`MaxEntries`、`MaxDataBytes`、`RequireReadOnlyFiles` | `Size` | 获取有界目录树结果 | 响应 payload 以一个 uint16 结果开头:1 表示成功,后跟响应的字段;2 表示失败,后跟一个 [`Failure`](#failures)。payload 按以下顺序列出字段: @@ -159,9 +160,11 @@ GetLock Handle u64, Owner u64, Lock GetLockResponse Conflict optional Lock SetLock Handle u64, Kind enum, Owner u64, Lock, Wait bool; response (no fields) CancelRequest Target u64; response (no fields) +OpenTree Handle u64, Node NodeRef, MaxEntries u32, MaxDataBytes u64, RequireReadOnlyFiles bool +OpenTreeResponse Size u64 ``` -[`testdata`](https://github.com/MiniMax-AI/OpenAgentCore/tree/main/internal/sandboxfs/testdata) 保存带注释的 golden frame,涵盖 `Describe`、`Walk`、`Create`、一次追加 `Write`、一次短 `Write`、带 cookie 的 `ReadDir`、`Rename` 和一次失败。 +[`testdata`](https://github.com/MiniMax-AI/OpenAgentCore/tree/main/internal/sandboxfs/testdata) 保存带注释的 golden frame,涵盖 `Describe`、`OpenTree` 及其树结果、`Walk`、`Create`、一次追加 `Write`、一次短 `Write`、带 cookie 的 `ReadDir`、`Rename` 和一次失败。 ### 共享类型 {#shared-types} @@ -198,6 +201,8 @@ Lock Mode enum (LockRead = 1, LockWrite = 2, LockUnlock = 3), Start u64 | `MaxWalkComponents` | u32 | `Walk` 名称数上限,1 到 1024 | | `MaxReadDirBytes` | u32 | 最大 `ReadDir` limit,1 到 256 KiB | | `MaxOpenHandles` | u32 | 每个 attachment 的打开 handle 数上限,至少为 1 | +| `MaxTreeEntries` | u32 | `OpenTree` 条目数上限(包含根);与 `MaxTreeDataBytes` 同为零表示不支持 | +| `MaxTreeDataBytes` | u64 | `OpenTree` 普通文件总字节数上限;最大编码结果不得超过 2^63−1 字节 | | `ReadOnly` | bool | 服务仅接受只读 attachment | | `AtomicAppend` | bool | `Write` 支持 `Append`,且来自多个 handle 的追加在单次写入内绝不交错 | | `AtomicRename` | bool | `RenameReplace` 原子替换目标 | @@ -209,7 +214,7 @@ Lock Mode enum (LockRead = 1, LockWrite = 2, LockUnlock = 3), Start u64 | `Flock` | bool | `SetLock` 支持 `LockFlock` | | `POSIXLocks` | bool | `GetLock` 和 `SetLock` 支持 `LockPOSIX` | -可写服务(即未声明 `ReadOnly` 的服务)声明 `AtomicAppend`、`AtomicRename`、`HardLinks` 和 `Symlinks`。超出声明限制的请求以 `InvalidArgument` 失败,名称或目标超限则以 `Errno` `NameTooLong` 失败;请求未声明的功能以 `Unsupported` 失败。 +可写服务(即未声明 `ReadOnly` 的服务)声明 `AtomicAppend`、`AtomicRename`、`HardLinks` 和 `Symlinks`。`OpenTree` 超出声明组合时以 `Unsupported` 失败。其他超出声明限制的请求以 `InvalidArgument` 失败,名称或目标超限则以 `Errno` `NameTooLong` 失败;请求未声明的功能以 `Unsupported` 失败。 ### Attach {#attach} @@ -257,7 +262,7 @@ Lock Mode enum (LockRead = 1, LockWrite = 2, LockUnlock = 3), Start u64 ### Handle {#handles} -- 客户端为每个 `Open`、`Create` 和 `OpenDir` 选择 `HandleID`:非零,且从未在该 attachment 的任何 stream 上使用过。`sandboxfs.HandleIDs` 按递增顺序分配 ID。 +- 客户端为每个 `Open`、`Create`、`OpenDir` 和 `OpenTree` 选择 `HandleID`:非零,且从未在该 attachment 的任何 stream 上使用过。`sandboxfs.HandleIDs` 按递增顺序分配 ID。 - 服务在请求产生任何文件系统作用前预留 ID。获取操作运行期间,预留的 ID 计入 `MaxOpenHandles`。ID 已被预留或已打开的获取操作以 `InvalidArgument` 和 `EffectNone` 失败。 - 其他指定预留 ID 的请求以 `StaleHandle` 失败,`Release` 和 `ReleaseDir` 除外:server 仅在该 ID 的获取操作结束后运行它们,因此它们关闭该操作打开的 handle。 - 对某 ID 的 `Release` 或 `ReleaseDir` 可以结算结果未知的获取操作,无论其回复随 stream 丢失,还是其调用被取消。成功或 `StaleHandle` 证明不再有该 ID 的 handle。它不证明获取操作没有改变任何东西:`Create` 可能已创建文件,带截断的 `Open` 可能已截断文件。 @@ -276,6 +281,20 @@ Lock Mode enum (LockRead = 1, LockWrite = 2, LockUnlock = 3), Start u64 - 带 `WithAttrs` 时,每个返回的条目携带一个 `Entry`,附带一个 lookup 引用。 - 没有剩余条目时 `End` 为 true,没有条目的页始终设置它。同一页内名称和 cookie 都不重复。对于读取过程中发生变化的目录,`ReadDir` 不保证快照。 +### 有界树读取 {#bounded-tree-reads} + +`OpenTree` 读取已授权 `NodeRef` 指定的目录。`MaxEntries` 非零且包含根;`MaxDataBytes` 累计普通文件字节数,可以为零。两者必须符合声明的 capabilities。`RequireReadOnlyFiles` 拒绝存在任何写权限位的普通文件。服务应用与其他读取相同的 attachment、lease、export 和 OS 权限检查。它仅接受目录与普通文件,不跟随 symlink,也不返回新的 node 引用。调用方保留原有验证及业务限额。 + +服务先枚举并保留每个对象的身份和属性,再从同一批对象读取内容。路径重命名、解除链接或被替换,都不会把已捕获对象重定向到另一 inode。每个文件必须恰好读出捕获的大小,且没有额外字节。目录枚举可观察到并发变化;此操作不是原子文件系统快照,也不检测同大小的并发写入。失败不会发布部分结果。超过条目或数据限额返回 `Errno` `Overflow`;不合法类型、`RequireReadOnlyFiles` 下的写权限位、重名或文件长度变化返回 `Errno` `InvalidArgument`。其他 OS 失败保持原有类型映射。 + +不可变结果通过现有基于偏移量的 `Read` 读取,并以 `Release` 关闭。`OpenTreeResponse.Size` 是其精确编码长度。结果由 `Count u32, DataBytes u64` 开头,之后恰好有 `Count` 个记录,格式为 `ParentIndex u32, Name bytes, Attr`;仅普通文件后跟 `Attr.Size` 个原始内容字节。记录 0 是目录,parent 为 0、name 为空。其他名称必须是合法的单个组件,其 parent 必须是之前出现且仍在活动祖先栈中的目录。记录按深度优先排列,每个目录的子项名称按字节严格递增。返回已离开的子树无效。目录保留真实属性但不带内容。所有普通文件大小之和等于 `DataBytes`。最大编码长度为 `12 + MaxEntries × (96 + MaxNameBytes) + MaxDataBytes`,计算不得溢出;不允许尾随数据。此流可以大于 frame 限额,任何基本字段都可以跨 `Read` chunk 切分。权威协议中的 `NewTreeEncoder` 与 `NewTreeDecoder` 增量验证相同规则,不再积累一份完整编码内容;解码器必须在读取下一个记录前消费完当前内容。 + +结果 handle 支持 `Read`、`Release` 和 `GetAttr(TargetHandle)`;后者描述普通文件、mode 为 0400 的编码结果,而非源目录。`Flush` 成功且不改变状态。`Write` 和 `SetAttr(TargetHandle)` 返回 `Errno` `ReadOnlyFilesystem`;`Fsync`、`GetLock` 和 `SetLock` 返回 `Unsupported`;`ReadDir` 和 `ReleaseDir` 返回 `Errno` `BadDescriptor`。结果不具有可寻址的 `NodeRef`。 + +`OpenTree` 是获取操作,与 `Open` 共享 ID 预留、取消及 release fence。取消已发送请求可能留下 handle,因此报告 `EffectPossible`;使用独立清理 context 调用 `Release` 结算。构建期间在枚举及读取过程中检查取消。失败清理先关闭源及结果描述符,再归还资源;成功发布前先关闭源描述符。Detach、lease 结束和服务关闭会停止准入、取消并等待未结束的获取操作,然后关闭结果。slot 和字节预留一直保留到 backing 描述符实际关闭,包括仍在使用它的读取。 + +Linux 服务为每个结果使用一个 sealed 匿名内存文件(`memfd`)。在枚举之前预留最大编码大小:每个服务至多四个正在构建或仍保留的结果,每个 attachment 至多一个,结果 backing 总预留不超过 128 MiB,同时遵守原有 `MaxOpenHandles` 限额。超过任一预算的准入返回 `ResourceExhausted` 和 `EffectNone`,不等待或重放请求。内存文件发布前禁止写入、增长和缩短;创建或 sealing 不受支持时直接失败,不切换存储路径。这些限额约束结果 backing,而非整个进程 RSS。增量枚举以 `MaxEntries` 限制保留对象和名称并拒绝重名,在下钻前关闭每个枚举描述符。内容传输只需一个 chunk 缓冲区。不使用命名临时文件、持久缓存或应用专有格式。 + ### Rename {#rename} `Rename` 恰好接受一种模式:`RenameReplace`(1)替换已存在的目标,`RenameNoReplace`(2)在目标存在时以 `Errno` `Exists` 失败,`RenameExchange`(3)交换两个已存在的条目。 @@ -306,7 +325,7 @@ Failure | 5 | `InstanceChanged` | stream 绑定到另一个服务 incarnation | | 6 | `StaleNode` | attachment 未持有该 `NodeRef` | | 7 | `StaleHandle` | attachment 没有该打开的 handle | -| 8 | `ResourceExhausted` | stream 已持有 `MaxInFlight` 个请求,或 attachment 已有 `MaxOpenHandles` 个 handle(含预留 ID) | +| 8 | `ResourceExhausted` | stream 已持有 `MaxInFlight` 个请求、attachment 已有 `MaxOpenHandles` 个 handle(含预留 ID),或有界树结果预算耗尽 | | 9 | `Cancelled` | 请求已被取消 | | 10 | `DeadlineExceeded` | 调用方的截止时间已过 | | 11 | `Errno` | 文件系统调用失败;`Errno` 说明原因 | diff --git a/internal/sandboxfs/client.go b/internal/sandboxfs/client.go index 51849065c..b6e08e6f8 100644 --- a/internal/sandboxfs/client.go +++ b/internal/sandboxfs/client.go @@ -31,7 +31,7 @@ type Client struct { afterWrite func() // test seam: runs once a frame is written } -// HandleIDs allocates the handle IDs a client chooses for Open, Create and +// HandleIDs allocates the handle IDs a client chooses for Open, Create, OpenTree and // OpenDir: 1, 2, 3 and so on. An attachment keeps one allocator across all of // its streams, so it never uses an ID twice. Its methods are safe for // concurrent use. @@ -346,6 +346,10 @@ func (c *Client) Open(ctx context.Context, r *OpenRequest) (*OpenResponse, error return roundTrip[*OpenResponse](ctx, c, r) } +func (c *Client) OpenTree(ctx context.Context, r *OpenTreeRequest) (*OpenTreeResponse, error) { + return roundTrip[*OpenTreeResponse](ctx, c, r) +} + func (c *Client) Create(ctx context.Context, r *CreateRequest) (*CreateResponse, error) { return roundTrip[*CreateResponse](ctx, c, r) } diff --git a/internal/sandboxfs/protocol.go b/internal/sandboxfs/protocol.go index aa4725d61..389e85385 100644 --- a/internal/sandboxfs/protocol.go +++ b/internal/sandboxfs/protocol.go @@ -6,9 +6,12 @@ package sandboxfs import ( + "bytes" "context" + "encoding/binary" "errors" "fmt" + "io" "math" "github.com/MiniMax-AI/OpenAgentCore/internal/sandboxlink" @@ -17,7 +20,7 @@ import ( // Version is the protocol version. Link matches it exactly when it opens a // file stream. -const Version uint16 = 2 +const Version uint16 = 3 // Op is a request tag. Each response carries its request's tag with // sandboxwire.ResponseType. The protocol has no events. @@ -54,6 +57,7 @@ const ( OpGetLock OpSetLock OpCancelRequest + OpOpenTree ) var opNames = [...]string{ @@ -62,7 +66,7 @@ var opNames = [...]string{ OpOpen: "Open", OpCreate: "Create", OpRead: "Read", OpWrite: "Write", OpFlush: "Flush", OpFsync: "Fsync", OpRelease: "Release", OpOpenDir: "OpenDir", OpReadDir: "ReadDir", OpReleaseDir: "ReleaseDir", OpMkdir: "Mkdir", OpUnlink: "Unlink", OpRmdir: "Rmdir", OpRename: "Rename", OpLink: "Link", OpSymlink: "Symlink", OpReadlink: "Readlink", - OpStatFS: "StatFS", OpForget: "Forget", OpGetLock: "GetLock", OpSetLock: "SetLock", OpCancelRequest: "CancelRequest", + OpStatFS: "StatFS", OpForget: "Forget", OpGetLock: "GetLock", OpSetLock: "SetLock", OpCancelRequest: "CancelRequest", OpOpenTree: "OpenTree", } func (o Op) String() string { @@ -72,7 +76,7 @@ func (o Op) String() string { return fmt.Sprintf("Op(%d)", uint16(o)) } -var tags = sandboxwire.Tags{Requests: uint16(OpCancelRequest)} +var tags = sandboxwire.Tags{Requests: uint16(OpOpenTree)} // Hard limits. Capabilities may advertise smaller ones; nothing on the wire // exceeds these. @@ -139,7 +143,7 @@ type Lease interface { // target's context. A method returns a *Failure for a typed failure; the server // reports any other error as Unknown with EffectPossible. A method publishes // every state it creates before it returns. The server never runs two -// acquisitions (Open, Create, OpenDir) of one handle ID of an attachment at +// acquisitions (Open, Create, OpenDir, OpenTree) of one handle ID of an attachment at // once, nor a Release or ReleaseDir while the acquisition of its ID runs. type Service interface { Describe(context.Context, Attachment, *DescribeRequest) (*DescribeResponse, error) @@ -151,6 +155,7 @@ type Service interface { SetAttr(context.Context, Attachment, *SetAttrRequest) (*SetAttrResponse, error) Access(context.Context, Attachment, *AccessRequest) (*AccessResponse, error) Open(context.Context, Attachment, *OpenRequest) (*OpenResponse, error) + OpenTree(context.Context, Attachment, *OpenTreeRequest) (*OpenTreeResponse, error) Create(context.Context, Attachment, *CreateRequest) (*CreateResponse, error) Read(context.Context, Attachment, *ReadRequest) (*ReadResponse, error) Write(context.Context, Attachment, *WriteRequest) (*WriteResponse, error) @@ -459,6 +464,8 @@ type Capabilities struct { MaxWalkComponents uint32 MaxReadDirBytes uint32 MaxOpenHandles uint32 // per attachment + MaxTreeEntries uint32 // zero together with MaxTreeDataBytes means unsupported + MaxTreeDataBytes uint64 ReadOnly bool AtomicAppend bool AtomicRename bool @@ -667,6 +674,18 @@ type OpenRequest struct { type OpenResponse struct{} +// OpenTreeRequest acquires a bounded, immutable encoding of a directory tree. +// MaxEntries includes the root; MaxDataBytes sums regular-file content only. +type OpenTreeRequest struct { + Handle HandleID + Node NodeRef + MaxEntries uint32 + MaxDataBytes uint64 + RequireReadOnlyFiles bool +} + +type OpenTreeResponse struct{ Size uint64 } + // CreateRequest creates and opens a regular file as Handle, an ID the client // chose and never used before in the attachment. type CreateRequest struct { @@ -888,6 +907,7 @@ func (*GetAttrRequest) Op() Op { return OpGetAttr } func (*SetAttrRequest) Op() Op { return OpSetAttr } func (*AccessRequest) Op() Op { return OpAccess } func (*OpenRequest) Op() Op { return OpOpen } +func (*OpenTreeRequest) Op() Op { return OpOpenTree } func (*CreateRequest) Op() Op { return OpCreate } func (*ReadRequest) Op() Op { return OpRead } func (*WriteRequest) Op() Op { return OpWrite } @@ -955,11 +975,11 @@ func bind[QT, RT any, Q interface { } // opSpecs is indexed by Op; each request type's Op method places its entry. -var opSpecs = func() (t [OpCancelRequest + 1]opSpec) { +var opSpecs = func() (t [OpOpenTree + 1]opSpec) { for _, s := range []opSpec{ bind(Service.Describe), bind(Service.Attach), bind(Service.Detach), bind(Service.Lookup), bind(Service.Walk), bind(Service.GetAttr), bind(Service.SetAttr), bind(Service.Access), - bind(Service.Open), bind(Service.Create), bind(Service.Read), bind(Service.Write), + bind(Service.Open), bind(Service.OpenTree), bind(Service.Create), bind(Service.Read), bind(Service.Write), bind(Service.Flush), bind(Service.Fsync), bind(Service.Release), bind(Service.OpenDir), bind(Service.ReadDir), bind(Service.ReleaseDir), bind(Service.Mkdir), bind(Service.Unlink), bind(Service.Rmdir), bind(Service.Rename), @@ -992,6 +1012,8 @@ func acquires(r Request) (HandleID, bool) { switch r := r.(type) { case *OpenRequest: return r.Handle, true + case *OpenTreeRequest: + return r.Handle, true case *CreateRequest: return r.Handle, true case *OpenDirRequest: @@ -1042,6 +1064,10 @@ func (c *Capabilities) Admit(r Request, readOnly bool) *Failure { if !r.ReadOnly && c.ReadOnly { return unsupported("a writable attachment") } + case *OpenTreeRequest: + if c.MaxTreeEntries == 0 || c.MaxTreeDataBytes == 0 || r.MaxEntries > c.MaxTreeEntries || r.MaxDataBytes > c.MaxTreeDataBytes { + return unsupported("OpenTree limits") + } case *LookupRequest: if tooLong(r.Name, c.MaxNameBytes) { return nameTooLong @@ -1170,7 +1196,7 @@ func encodeRequest(r Request) ([]byte, error) { } func decodeRequest(op Op, payload []byte) (Request, error) { - if op < 1 || op > OpCancelRequest { + if op < 1 || op > OpOpenTree { return nil, malformed("unknown request %d", uint16(op)) } r := opSpecs[op].newRequest() @@ -1198,7 +1224,7 @@ func encodeResponse(r message, f *Failure) ([]byte, error) { } func decodeResponse(op Op, payload []byte) (message, *Failure, error) { - if op < 1 || op > OpCancelRequest { + if op < 1 || op > OpOpenTree { return nil, nil, malformed("unknown request %d", uint16(op)) } d := sandboxwire.NewDecoder(payload) @@ -1454,6 +1480,8 @@ func (c *Capabilities) encode(e *sandboxwire.Encoder) { for _, v := range []uint32{c.MaxNameBytes, c.MaxPathBytes, c.MaxReadBytes, c.MaxWriteBytes, c.MaxWalkComponents, c.MaxReadDirBytes, c.MaxOpenHandles} { e.U32(v) } + e.U32(c.MaxTreeEntries) + e.U64(c.MaxTreeDataBytes) for _, v := range c.flags() { e.Bool(*v) } @@ -1466,6 +1494,8 @@ func (c *Capabilities) decode(d *sandboxwire.Decoder) { for _, v := range []*uint32{&c.MaxNameBytes, &c.MaxPathBytes, &c.MaxReadBytes, &c.MaxWriteBytes, &c.MaxWalkComponents, &c.MaxReadDirBytes, &c.MaxOpenHandles} { *v = d.U32() } + c.MaxTreeEntries = d.U32() + c.MaxTreeDataBytes = d.U64() for _, v := range c.flags() { *v = d.Bool() } @@ -1492,6 +1522,8 @@ func (c *Capabilities) validate() error { !within(c.MaxWalkComponents, maxWalkNames), !within(c.MaxReadDirBytes, maxReadDirBytes), c.MaxOpenHandles == 0: return malformed("capability limit out of range") + case (c.MaxTreeEntries == 0) != (c.MaxTreeDataBytes == 0) || c.MaxTreeDataBytes > maxOffset-12-uint64(c.MaxTreeEntries)*(8+attrWireSize+uint64(c.MaxNameBytes)): + return malformed("tree capability limit out of range") case !c.ReadOnly && !(c.AtomicAppend && c.AtomicRename && c.HardLinks && c.Symlinks): return malformed("a writable service requires AtomicAppend, AtomicRename, HardLinks and Symlinks") } @@ -2242,3 +2274,338 @@ func (r *CancelRequestRequest) validate() error { func (*CancelRequestResponse) encode(*sandboxwire.Encoder) {} func (*CancelRequestResponse) decode(*sandboxwire.Decoder) {} func (*CancelRequestResponse) validate() error { return nil } + +func (r *OpenTreeRequest) encode(e *sandboxwire.Encoder) { + e.U64(uint64(r.Handle)) + r.Node.encode(e) + e.U32(r.MaxEntries) + e.U64(r.MaxDataBytes) + e.Bool(r.RequireReadOnlyFiles) +} +func (r *OpenTreeRequest) decode(d *sandboxwire.Decoder) { + r.Handle = HandleID(d.U64()) + r.Node.decode(d) + r.MaxEntries = d.U32() + r.MaxDataBytes = d.U64() + r.RequireReadOnlyFiles = d.Bool() +} +func (r *OpenTreeRequest) validate() error { + if r.MaxEntries == 0 || r.MaxDataBytes > maxOffset { + return malformed("tree request limits") + } + return errors.Join(r.Handle.validate(), r.Node.validate()) +} +func (r *OpenTreeResponse) encode(e *sandboxwire.Encoder) { e.U64(r.Size) } +func (r *OpenTreeResponse) decode(d *sandboxwire.Decoder) { r.Size = d.U64() } +func (r *OpenTreeResponse) validate() error { + if r.Size < 12+8+attrWireSize || r.Size > maxOffset { + return malformed("tree result size %d", r.Size) + } + return nil +} + +// TreeSizeBound is the maximum encoded length for an admitted OpenTree request. +// It includes the header, bounded names and attributes, and regular-file bytes. +func TreeSizeBound(req OpenTreeRequest, caps Capabilities) (uint64, error) { + if err := req.validate(); err != nil { + return 0, err + } + if err := caps.validate(); err != nil { + return 0, err + } + if fail := caps.Admit(&req, false); fail != nil { + return 0, fail + } + return 12 + uint64(req.MaxEntries)*(8+attrWireSize+uint64(caps.MaxNameBytes)) + req.MaxDataBytes, nil +} + +// TreeRecord describes one captured object. Parent is its parent's record index. +// The first record is the root directory, with Parent zero and an empty Name. +type TreeRecord struct { + Parent uint32 + Name []byte + Attr Attr +} + +// ValidateTreeEntry checks the per-entry rules before a service retains an +// object's descriptor. Root entries have an empty name and must be directories. +// Aggregate counts, data limits and traversal order are checked by the codec. +func ValidateTreeEntry(record TreeRecord, root bool, req OpenTreeRequest, caps Capabilities) error { + if err := record.Attr.validate(); err != nil { + return err + } + kind := record.Attr.Mode & ModeType + if root { + if record.Parent != 0 || len(record.Name) != 0 || kind != ModeDirectory { + return malformed("invalid tree root") + } + } else if err := validName(record.Name); err != nil { + return err + } else if uint64(len(record.Name)) > uint64(caps.MaxNameBytes) { + return malformed("tree name exceeds MaxNameBytes") + } + if kind != ModeDirectory && kind != ModeRegular { + return malformed("unsupported tree file type") + } + if kind == ModeRegular && req.RequireReadOnlyFiles && record.Attr.Mode&0o222 != 0 { + return malformed("writable tree file") + } + return nil +} + +type treeDirectory struct { + index uint32 + lastName []byte +} +type treeState struct { + req OpenTreeRequest + caps Capabilities + count, index uint32 + dataBytes, seenBytes uint64 + stack []treeDirectory +} + +func newTreeState(req OpenTreeRequest, caps Capabilities, count uint32, dataBytes uint64) (treeState, error) { + s := treeState{req: req, caps: caps, count: count, dataBytes: dataBytes} + if _, err := TreeSizeBound(req, caps); err != nil { + return s, err + } + if count == 0 || count > req.MaxEntries || dataBytes > req.MaxDataBytes { + return s, malformed("tree header exceeds request") + } + return s, nil +} + +func (s *treeState) accept(r TreeRecord) error { + if s.index >= s.count { + return malformed("extra tree record") + } + if err := ValidateTreeEntry(r, s.index == 0, s.req, s.caps); err != nil { + return err + } + if s.index != 0 { + // A parent must remain on the active ancestor stack. Once a subtree was + // left, returning to it would violate depth-first order. + for len(s.stack) > 0 && s.stack[len(s.stack)-1].index != r.Parent { + s.stack = s.stack[:len(s.stack)-1] + } + if len(s.stack) == 0 { + return malformed("tree parent is not an active ancestor") + } + p := &s.stack[len(s.stack)-1] + if bytes.Compare(p.lastName, r.Name) >= 0 { + return malformed("tree siblings are not strictly ordered") + } + p.lastName = bytes.Clone(r.Name) + } + if r.Attr.Mode&ModeType == ModeDirectory { + s.stack = append(s.stack, treeDirectory{index: s.index}) + } else { + if r.Attr.Size > s.dataBytes-s.seenBytes { + return malformed("tree data exceeds header") + } + s.seenBytes += r.Attr.Size + } + s.index++ + return nil +} +func (s *treeState) finish() error { + if s.index != s.count || s.seenBytes != s.dataBytes { + return malformed("tree totals do not match header") + } + return nil +} + +// TreeEncoder writes the sole OpenTree result encoding incrementally. An error +// is terminal; discard the result. Close validates totals but never closes w. +type TreeEncoder struct { + w io.Writer + state treeState + scratch [sandboxwire.MaxChunk]byte + err error + closed bool +} + +func NewTreeEncoder(w io.Writer, req OpenTreeRequest, caps Capabilities, count uint32, dataBytes uint64) (*TreeEncoder, error) { + state, err := newTreeState(req, caps, count, dataBytes) + if err != nil { + return nil, err + } + e := &TreeEncoder{w: w, state: state} + var h sandboxwire.Encoder + h.U32(count) + h.U64(dataBytes) + e.err = writeTreeBytes(w, h.Payload()) + if e.err != nil { + return nil, e.err + } + return e, nil +} +func writeTreeBytes(w io.Writer, data []byte) error { + n, err := w.Write(data) + if err == nil && n != len(data) { + err = io.ErrShortWrite + } + return err +} + +// Write emits a record and exactly Attr.Size bytes for a regular file. Its +// reader must end there; an additional byte rejects a file that grew. Directory +// bodies must be nil. Memory use does not grow with the file's size. +func (e *TreeEncoder) Write(record TreeRecord, body io.Reader) error { + if e.err != nil { + return e.err + } + if e.closed { + return malformed("tree encoder closed") + } + e.err = e.write(record, body) + return e.err +} +func (e *TreeEncoder) write(record TreeRecord, body io.Reader) error { + if err := e.state.accept(record); err != nil { + return err + } + regular := record.Attr.Mode&ModeType == ModeRegular + if regular && body == nil || !regular && body != nil { + return malformed("tree body does not match file type") + } + var h sandboxwire.Encoder + h.U32(record.Parent) + h.Bytes(record.Name) + record.Attr.encode(&h) + if err := writeTreeBytes(e.w, h.Payload()); err != nil { + return err + } + if !regular { + return nil + } + remaining := record.Attr.Size + for remaining > 0 { + size := min(remaining, uint64(len(e.scratch))) + if _, err := io.ReadFull(body, e.scratch[:size]); err != nil { + return err + } + if err := writeTreeBytes(e.w, e.scratch[:size]); err != nil { + return err + } + remaining -= size + } + var extra [1]byte + n, err := io.ReadFull(body, extra[:]) + if n != 0 { + return malformed("tree file grew") + } + if err != io.EOF { + return err + } + return nil +} +func (e *TreeEncoder) Close() error { + if e.err == nil { + e.err = e.state.finish() + } + e.closed = true + return e.err +} + +// TreeDecoder validates a result while exposing each file body as a bounded +// reader. Consume it completely before Next; Next returning EOF validates the +// totals, exact response Size and absence of trailing data. It never closes r. +type TreeDecoder struct { + r *io.LimitedReader + source io.Reader + state treeState + body *io.LimitedReader + err error + done bool +} + +func NewTreeDecoder(r io.Reader, req OpenTreeRequest, caps Capabilities, size uint64) (*TreeDecoder, error) { + bound, err := TreeSizeBound(req, caps) + if err != nil { + return nil, err + } + if size < 12+8+attrWireSize || size > bound { + return nil, malformed("tree response exceeds size bound") + } + d := &TreeDecoder{source: r, r: &io.LimitedReader{R: r, N: int64(size)}} + var header [12]byte + if _, err = io.ReadFull(d.r, header[:]); err != nil { + return nil, fmt.Errorf("tree header: %w", err) + } + d.state, err = newTreeState(req, caps, binary.BigEndian.Uint32(header[:4]), binary.BigEndian.Uint64(header[4:])) + if err != nil { + return nil, err + } + return d, nil +} +func (d *TreeDecoder) Next() (TreeRecord, io.Reader, error) { + if d.err != nil { + return TreeRecord{}, nil, d.err + } + if d.done { + return TreeRecord{}, nil, io.EOF + } + record, body, err := d.next() + if err == io.EOF { + d.done = true + } else if err != nil { + d.err = err + } + return record, body, err +} +func (d *TreeDecoder) next() (TreeRecord, io.Reader, error) { + var record TreeRecord + if d.body != nil && d.body.N != 0 { + return record, nil, malformed("tree body not consumed") + } + if d.state.index == d.state.count { + if err := d.state.finish(); err != nil { + return record, nil, err + } + if d.r.N != 0 { + return record, nil, malformed("tree encoded length differs from response") + } + var extra [1]byte + n, err := io.ReadFull(d.source, extra[:]) + if n != 0 { + return record, nil, malformed("trailing tree bytes") + } + if err != io.EOF { + return record, nil, err + } + return record, nil, io.EOF + } + var prefix [8]byte + if _, err := io.ReadFull(d.r, prefix[:]); err != nil { + return record, nil, fmt.Errorf("tree record: %w", err) + } + record.Parent = binary.BigEndian.Uint32(prefix[:4]) + size := binary.BigEndian.Uint32(prefix[4:]) + if size > d.state.caps.MaxNameBytes { + return record, nil, malformed("tree name exceeds MaxNameBytes") + } + data := make([]byte, int(size)+attrWireSize) + if _, err := io.ReadFull(d.r, data); err != nil { + return record, nil, fmt.Errorf("tree record attributes: %w", err) + } + record.Name = data[:size:size] + attr := sandboxwire.NewDecoder(data[size:]) + record.Attr.decode(attr) + if err := attr.Finish(); err != nil { + return record, nil, err + } + if err := d.state.accept(record); err != nil { + return record, nil, err + } + var bodySize uint64 + if record.Attr.Mode&ModeType == ModeRegular { + bodySize = record.Attr.Size + } + if bodySize > uint64(d.r.N) { + return record, nil, malformed("tree body exceeds result size") + } + d.body = &io.LimitedReader{R: d.r, N: int64(bodySize)} + return record, d.body, nil +} diff --git a/internal/sandboxfs/protocol_test.go b/internal/sandboxfs/protocol_test.go index ac70e41d2..439f4bf2a 100644 --- a/internal/sandboxfs/protocol_test.go +++ b/internal/sandboxfs/protocol_test.go @@ -23,7 +23,7 @@ var ( testCaps = Capabilities{ PathProfile: PathProfileLinuxBytes, CacheProfile: CacheProfileUncached, Durability: DurabilityFsyncRequired, MaxNameBytes: 255, MaxPathBytes: 4095, MaxReadBytes: 65536, MaxWriteBytes: 65536, MaxWalkComponents: 256, - MaxReadDirBytes: 65536, MaxOpenHandles: 4096, AtomicAppend: true, AtomicRename: true, RenameNoReplace: true, + MaxReadDirBytes: 65536, MaxOpenHandles: 4096, MaxTreeEntries: 4096, MaxTreeDataBytes: 32 << 20, AtomicAppend: true, AtomicRename: true, RenameNoReplace: true, RenameExchange: true, HardLinks: true, Symlinks: true, SetMode: true, SetOwner: true, SetTimes: true, DirectoryFsync: true, ReadDirPlus: true, Flock: true, } @@ -43,6 +43,8 @@ func TestGoldenFixtures(t *testing.T) { }{ {file: "describe_response.hex", op: OpDescribe, id: 1, resp: &DescribeResponse{ ServerInstanceID: testInstance, Identity: Identity{UID: 1000, GID: 1000}, Capabilities: testCaps, Exports: []sandboxlink.ExportID{WorldExport}}}, + {file: "opentree_request.hex", op: OpOpenTree, id: 9, req: &OpenTreeRequest{Handle: 7, Node: testNode, MaxEntries: 1000, MaxDataBytes: 20 << 20, RequireReadOnlyFiles: true}}, + {file: "opentree_response.hex", op: OpOpenTree, id: 9, resp: &OpenTreeResponse{Size: 108}}, {file: "walk_request.hex", op: OpWalk, id: 2, req: &WalkRequest{Parent: testNode, Names: [][]byte{[]byte("link"), []byte("x")}}}, {file: "walk_response.hex", op: OpWalk, id: 2, resp: &WalkResponse{Entries: []Entry{{Node: NodeRef{ID: 2, Generation: 7}, Attr: symlink}}}}, {file: "create_request.hex", op: OpCreate, id: 3, req: &CreateRequest{ @@ -141,13 +143,14 @@ func samples() []struct { {&GetLockRequest{Handle: 4, Owner: 12, Lock: Lock{Mode: LockRead, Start: 0, End: 99}}, &GetLockResponse{Conflict: &Lock{Mode: LockWrite, Start: 50, End: 60}}}, {&SetLockRequest{Handle: 4, Kind: LockFlock, Owner: 12, Lock: flock, Wait: true}, &SetLockResponse{}}, {&CancelRequestRequest{Target: 9}, &CancelRequestResponse{}}, + {&OpenTreeRequest{Handle: 7, Node: testNode, MaxEntries: 1000, MaxDataBytes: 20 << 20, RequireReadOnlyFiles: true}, &OpenTreeResponse{Size: 108}}, } } func TestRoundTripEveryMessage(t *testing.T) { all := samples() - if len(all) != int(OpCancelRequest) { - t.Fatalf("%d samples for %d operations", len(all), OpCancelRequest) + if len(all) != int(OpOpenTree) { + t.Fatalf("%d samples for %d operations", len(all), OpOpenTree) } for i, s := range all { op := Op(i + 1) diff --git a/internal/sandboxfs/server_test.go b/internal/sandboxfs/server_test.go index 0080cfd9e..7fd5578c5 100644 --- a/internal/sandboxfs/server_test.go +++ b/internal/sandboxfs/server_test.go @@ -138,6 +138,14 @@ func (o *ordered) Open(_ context.Context, _ Attachment, r *OpenRequest) (*OpenRe return &OpenResponse{}, nil } +func (o *ordered) OpenTree(_ context.Context, _ Attachment, r *OpenTreeRequest) (*OpenTreeResponse, error) { + o.hold() + o.mu.Lock() + defer o.mu.Unlock() + o.handles[r.Handle] = true + return &OpenTreeResponse{Size: 108}, nil +} + func (o *ordered) Release(_ context.Context, _ Attachment, r *ReleaseRequest) (*ReleaseResponse, error) { o.mu.Lock() defer o.mu.Unlock() @@ -182,6 +190,12 @@ func TestSuccessorWaitsForPredecessor(t *testing.T) { return err }, func(c *Client) error { _, err := c.Release(ctx, &ReleaseRequest{Handle: 7}); return err }}, + {"OpenTree then Release", + func(c *Client) error { + _, err := c.OpenTree(ctx, &OpenTreeRequest{Handle: 7, Node: testNode, MaxEntries: 1000, MaxDataBytes: 20 << 20}) + return err + }, + func(c *Client) error { _, err := c.Release(ctx, &ReleaseRequest{Handle: 7}); return err }}, } { t.Run(tc.name, func(t *testing.T) { svc := newOrdered() @@ -368,6 +382,11 @@ func TestEndedLeaseIsRefused(t *testing.T) { // the client cancelled the Open, waits and releases what the Open created. A // second acquisition of the pending ID is refused without effect. func TestReleaseFollowsPendingAcquisition(t *testing.T) { + for _, open := range []Request{&OpenRequest{Handle: 7, Node: testNode, Access: AccessRead}, &OpenTreeRequest{Handle: 7, Node: testNode, MaxEntries: 1000, MaxDataBytes: 20 << 20}} { + t.Run(open.Op().String(), func(t *testing.T) { testReleaseFollowsPendingAcquisition(t, open) }) + } +} +func testReleaseFollowsPendingAcquisition(t *testing.T, open Request) { svc := newOrdered() cc, sc := net.Pipe() defer cc.Close() @@ -402,7 +421,6 @@ func TestReleaseFollowsPendingAcquisition(t *testing.T) { return f.RequestID } - open := &OpenRequest{Handle: 7, Node: testNode, Access: AccessRead} send(1, open) <-svc.entered send(2, &CancelRequestRequest{Target: 1}) diff --git a/internal/sandboxfs/testdata/describe_response.hex b/internal/sandboxfs/testdata/describe_response.hex index a0b444f57..c19f88267 100644 --- a/internal/sandboxfs/testdata/describe_response.hex +++ b/internal/sandboxfs/testdata/describe_response.hex @@ -1,5 +1,5 @@ # Describe response. Hex bytes; text after # is a comment. -00000057 # PayloadLength 87 +00000063 # PayloadLength 99 8001 # MessageType: response to Describe (1) 0000 # Flags 0000000000000001 # RequestID 1 @@ -17,6 +17,8 @@ 00000100 # MaxWalkComponents 256 00010000 # MaxReadDirBytes 65536 00001000 # MaxOpenHandles 4096 +00001000 # MaxTreeEntries 4096 +0000000002000000 # MaxTreeDataBytes 32 MiB 00 # ReadOnly 01 01 # AtomicAppend, AtomicRename 01 01 # RenameNoReplace, RenameExchange diff --git a/internal/sandboxfs/testdata/opentree_request.hex b/internal/sandboxfs/testdata/opentree_request.hex new file mode 100644 index 000000000..8d3ff1cae --- /dev/null +++ b/internal/sandboxfs/testdata/opentree_request.hex @@ -0,0 +1,7 @@ +# OpenTree request with immutable regular files and business limits. +00000025 001f 0000 0000000000000009 # payload 37, tag 31, RequestID 9 +0000000000000007 # Handle +0000000000000001 0000000000000007 # NodeRef +000003e8 # MaxEntries 1000 including root +0000000001400000 # MaxDataBytes 20 MiB +01 # RequireReadOnlyFiles diff --git a/internal/sandboxfs/testdata/opentree_response.hex b/internal/sandboxfs/testdata/opentree_response.hex new file mode 100644 index 000000000..18fae41e1 --- /dev/null +++ b/internal/sandboxfs/testdata/opentree_response.hex @@ -0,0 +1,4 @@ +# OpenTree success with a root-only result (12-byte header + 96-byte record). +0000000a 801f 0000 0000000000000009 # payload 10, response tag 31, RequestID 9 +0001 # ResultSuccess +000000000000006c # Size 108 diff --git a/internal/sandboxfs/testdata/tree_result.hex b/internal/sandboxfs/testdata/tree_result.hex new file mode 100644 index 000000000..217258863 --- /dev/null +++ b/internal/sandboxfs/testdata/tree_result.hex @@ -0,0 +1,14 @@ +# Two records: root directory and regular file f with body abc. +00000002000000000000000300000000 +00000000000000000000000000004140 +00000000000000000000000000000000 +00000000000000000000000000000000 +00000000000000000000000000000000 +00000000000000000000000000000000 +00000000000000000000000000000000 +00000001660000000000000000000081 +40000000000000000000000000000000 +00000000000000000000000003000000 +00000000000000000000000000000000 +00000000000000000000000000000000 +00000000000000000000000000616263 diff --git a/internal/sandboxfs/tree_test.go b/internal/sandboxfs/tree_test.go new file mode 100644 index 000000000..721e40a8a --- /dev/null +++ b/internal/sandboxfs/tree_test.go @@ -0,0 +1,319 @@ +package sandboxfs + +import ( + "bytes" + "encoding/binary" + "errors" + "io" + "math" + "testing" + + "github.com/MiniMax-AI/OpenAgentCore/internal/sandboxwire" + "github.com/MiniMax-AI/OpenAgentCore/internal/sandboxwire/sandboxwiretest" +) + +func treeRequest() OpenTreeRequest { + return OpenTreeRequest{Handle: 1, Node: testNode, MaxEntries: 1000, MaxDataBytes: 20 << 20, RequireReadOnlyFiles: true} +} +func treeRecord(name string, parent uint32, kind uint32, size uint64) TreeRecord { + return TreeRecord{Parent: parent, Name: []byte(name), Attr: Attr{Mode: kind | 0o500, Size: size}} +} + +func encodeTree(t *testing.T, records []TreeRecord, bodies [][]byte) []byte { + t.Helper() + var total uint64 + for _, r := range records { + if r.Attr.Mode&ModeType == ModeRegular { + total += r.Attr.Size + } + } + var b bytes.Buffer + e, err := NewTreeEncoder(&b, treeRequest(), testCaps, uint32(len(records)), total) + if err != nil { + t.Fatal(err) + } + for i, r := range records { + var body io.Reader + if r.Attr.Mode&ModeType == ModeRegular { + body = bytes.NewReader(bodies[i]) + } + if err = e.Write(r, body); err != nil { + t.Fatal(err) + } + } + if err = e.Close(); err != nil { + t.Fatal(err) + } + return b.Bytes() +} + +func TestTreeStreamingLargeFileAndDeepDirectories(t *testing.T) { + // This is a valid business-sized single file, larger than a transport frame. + payload := bytes.Repeat([]byte{0xa5}, 20<<20) + records := []TreeRecord{treeRecord("", 0, ModeDirectory, 4096)} + bodies := [][]byte{nil} + for i := 1; i < 999; i++ { + records = append(records, treeRecord("directory", uint32(i-1), ModeDirectory, 4096)) + bodies = append(bodies, nil) + } + records = append(records, treeRecord("f", 998, ModeRegular, uint64(len(payload)))) + bodies = append(bodies, payload) + encoded := encodeTree(t, records, bodies) + // A stream may split every metadata primitive across reads. + d, err := NewTreeDecoder(&smallRead{r: bytes.NewReader(encoded)}, treeRequest(), testCaps, uint64(len(encoded))) + if err != nil { + t.Fatal(err) + } + for i := range records { + r, body, err := d.Next() + if err != nil { + t.Fatal(err) + } + if r.Parent != records[i].Parent || !bytes.Equal(r.Name, records[i].Name) || r.Attr != records[i].Attr { + t.Fatalf("record %d differs", i) + } + got, err := io.ReadAll(body) + if err != nil || !bytes.Equal(got, bodies[i]) { + t.Fatalf("body %d: %v", i, err) + } + } + if _, _, err = d.Next(); err != io.EOF { + t.Fatalf("finish: %v", err) + } +} + +type smallRead struct{ r io.Reader } + +func (r *smallRead) Read(p []byte) (int, error) { return r.r.Read(p[:min(len(p), 701)]) } + +func TestTreeRejectsInvalidStructure(t *testing.T) { + root := treeRecord("", 0, ModeDirectory, 0) + dir := treeRecord("a", 0, ModeDirectory, 0) + file := treeRecord("f", 1, ModeRegular, 0) + cases := map[string][]TreeRecord{ + "root-file": {treeRecord("", 0, ModeRegular, 0)}, + "root-name": {treeRecord("root", 0, ModeDirectory, 0)}, + "root-parent": {treeRecord("", 1, ModeDirectory, 0)}, + "future-parent": {root, treeRecord("a", 2, ModeDirectory, 0)}, + "file-parent": {root, treeRecord("a", 0, ModeRegular, 0), treeRecord("b", 1, ModeRegular, 0)}, + "reenter-subtree": {root, dir, file, treeRecord("b", 0, ModeDirectory, 0), treeRecord("g", 1, ModeRegular, 0)}, + "duplicate": {root, dir, dir}, + "order": {root, treeRecord("b", 0, ModeDirectory, 0), dir}, + "symlink": {root, treeRecord("a", 0, ModeSymlink, 0)}, + "slash": {root, treeRecord("a/b", 0, ModeRegular, 0)}, + "dot": {root, treeRecord("..", 0, ModeRegular, 0)}, + "nul": {root, treeRecord("a\x00b", 0, ModeRegular, 0)}, + } + writable := treeRecord("a", 0, ModeRegular, 0) + writable.Attr.Mode |= 0o200 + cases["writable"] = []TreeRecord{root, writable} + invalidAttr := dir + invalidAttr.Attr.Mtime.Nsec = 1e9 + cases["invalid-attr"] = []TreeRecord{root, invalidAttr} + for name, records := range cases { + t.Run(name, func(t *testing.T) { + // Bypass the encoder to exercise decoder safety against an untrusted peer. + var raw sandboxwire.Encoder + raw.U32(uint32(len(records))) + raw.U64(0) + for _, r := range records { + raw.U32(r.Parent) + raw.Bytes(r.Name) + r.Attr.encode(&raw) + } + d, err := NewTreeDecoder(bytes.NewReader(raw.Payload()), treeRequest(), testCaps, uint64(len(raw.Payload()))) + if err == nil { + for range records { + _, body, nextErr := d.Next() + err = nextErr + if err != nil { + break + } + _, err = io.Copy(io.Discard, body) + if err != nil { + break + } + } + if err == nil { + _, _, err = d.Next() + } + } + if err == nil || err == io.EOF { + t.Fatalf("accepted invalid structure: %v", err) + } + }) + } +} + +func TestTreeRejectsCountsSizesAndTruncation(t *testing.T) { + raw := encodeTree(t, []TreeRecord{treeRecord("", 0, ModeDirectory, 0), treeRecord("f", 0, ModeRegular, 3)}, [][]byte{nil, []byte("abc")}) + for _, tc := range []struct { + name string + mutate func([]byte) []byte + sizeDelta int + }{ + {"zero-count", func(b []byte) []byte { binary.BigEndian.PutUint32(b, 0); return b }, 0}, + {"excess-count", func(b []byte) []byte { binary.BigEndian.PutUint32(b, 1001); return b }, 0}, + {"excess-data", func(b []byte) []byte { binary.BigEndian.PutUint64(b[4:], 21<<20); return b }, 0}, + {"short-declared-data", func(b []byte) []byte { binary.BigEndian.PutUint64(b[4:], 2); return b }, 0}, + {"long-declared-data", func(b []byte) []byte { binary.BigEndian.PutUint64(b[4:], 4); return b }, 0}, + {"huge-name", func(b []byte) []byte { binary.BigEndian.PutUint32(b[112:], math.MaxUint32); return b }, 0}, + {"trailing", func(b []byte) []byte { return append(b, 1) }, 0}, + {"declared-short", func(b []byte) []byte { return b }, -1}, + {"declared-long", func(b []byte) []byte { return b }, 1}, + } { + t.Run(tc.name, func(t *testing.T) { + b := tc.mutate(bytes.Clone(raw)) + d, err := NewTreeDecoder(bytes.NewReader(b), treeRequest(), testCaps, uint64(len(raw)+tc.sizeDelta)) + if err == nil { + for { + _, body, e := d.Next() + if e != nil { + err = e + break + } + if _, err = io.Copy(io.Discard, body); err != nil { + break + } + } + } + if err == nil || err == io.EOF { + t.Fatalf("accepted: %v", err) + } + }) + } + for n := 0; n < len(raw); n++ { + d, err := NewTreeDecoder(bytes.NewReader(raw[:n]), treeRequest(), testCaps, uint64(len(raw))) + if err == nil { + for { + _, body, e := d.Next() + if e != nil { + err = e + break + } + if _, err = io.Copy(io.Discard, body); err != nil { + break + } + } + } + if err == nil || err == io.EOF { + t.Fatalf("accepted truncation at %d: %v", n, err) + } + } +} + +func TestTreeEncoderBodyAndTotals(t *testing.T) { + for _, body := range []string{"ab", "abcd"} { + var b bytes.Buffer + e, err := NewTreeEncoder(&b, treeRequest(), testCaps, 2, 3) + if err != nil { + t.Fatal(err) + } + if err = e.Write(treeRecord("", 0, ModeDirectory, 0), nil); err != nil { + t.Fatal(err) + } + if err = e.Write(treeRecord("f", 0, ModeRegular, 3), bytes.NewBufferString(body)); err == nil { + t.Fatal("accepted changed size") + } + if e.Close() == nil { + t.Fatal("lost encoder error") + } + } + e, err := NewTreeEncoder(io.Discard, treeRequest(), testCaps, 2, 0) + if err != nil { + t.Fatal(err) + } + if err = e.Write(treeRecord("", 0, ModeDirectory, 0), nil); err != nil { + t.Fatal(err) + } + if e.Close() == nil { + t.Fatal("accepted missing record") + } + raw := encodeTree(t, []TreeRecord{treeRecord("", 0, ModeDirectory, 0), treeRecord("f", 0, ModeRegular, 3)}, [][]byte{nil, []byte("abc")}) + d, err := NewTreeDecoder(bytes.NewReader(raw), treeRequest(), testCaps, uint64(len(raw))) + if err != nil { + t.Fatal(err) + } + d.Next() + d.Next() + if _, _, err = d.Next(); err == nil || err == io.EOF { + t.Fatal("accepted unread body") + } +} + +func TestOpenTreeAdmissionAndEffects(t *testing.T) { + req := treeRequest() + if id, ok := acquires(&req); !ok || id != req.Handle || sideEffectFree(&req) || modifiesFiles(&req) { + t.Fatal("wrong acquisition classification") + } + if err := testCaps.Admit(&req, true); err != nil { + t.Fatal(err) + } + for _, mutate := range []func(*Capabilities){func(c *Capabilities) { c.MaxTreeEntries = 0; c.MaxTreeDataBytes = 0 }, func(c *Capabilities) { c.MaxTreeEntries = 999 }, func(c *Capabilities) { c.MaxTreeDataBytes = 1 }} { + caps := testCaps + mutate(&caps) + if fail := caps.Admit(&req, false); fail == nil || fail.Code != CodeUnsupported { + t.Fatalf("admission: %v", fail) + } + } + caps := testCaps + caps.MaxTreeDataBytes = math.MaxUint64 + if caps.validate() == nil { + t.Fatal("accepted overflowing caps") + } + req.MaxDataBytes = 0 + if _, err := TreeSizeBound(req, testCaps); err != nil { + t.Fatal(err) + } + req.MaxEntries = 0 + if _, err := TreeSizeBound(req, testCaps); !errors.Is(err, sandboxwire.ErrMalformed) { + t.Fatalf("bad limits: %v", err) + } +} + +func TestTreeResultFixture(t *testing.T) { + want := sandboxwiretest.ReadHex(t, "tree_result.hex") + got := encodeTree(t, []TreeRecord{treeRecord("", 0, ModeDirectory, 0), treeRecord("f", 0, ModeRegular, 3)}, [][]byte{nil, []byte("abc")}) + if !bytes.Equal(got, want) { + t.Fatalf("encoded result differs: %x", got) + } + d, err := NewTreeDecoder(bytes.NewReader(want), treeRequest(), testCaps, uint64(len(want))) + if err != nil { + t.Fatal(err) + } + if root, _, err := d.Next(); err != nil || root.Attr.Mode != ModeDirectory|0o500 { + t.Fatalf("root %+v: %v", root, err) + } + r, body, err := d.Next() + if err != nil { + t.Fatal(err) + } + content, err := io.ReadAll(body) + if err != nil || string(r.Name) != "f" || r.Attr.Size != 3 || string(content) != "abc" { + t.Fatalf("file %+v %q: %v", r, content, err) + } + if _, _, err = d.Next(); err != io.EOF { + t.Fatalf("finish: %v", err) + } +} + +func FuzzTreeDecode(f *testing.F) { + f.Add(sandboxwiretest.ReadHex(f, "tree_result.hex")) + f.Add([]byte{}) + f.Add([]byte{0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0}) + f.Fuzz(func(t *testing.T, data []byte) { + d, err := NewTreeDecoder(bytes.NewReader(data), treeRequest(), testCaps, uint64(len(data))) + if err != nil { + return + } + for { + _, body, err := d.Next() + if err != nil { + return + } + if _, err = io.Copy(io.Discard, body); err != nil { + return + } + } + }) +} From fb3db50f23fc004ea40bd2d3c16adf0a4ec0d636 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Fri, 9 Oct 2026 18:11:35 +0000 Subject: [PATCH 2/2] Join repeated File handle releases --- .../sandboxio/internal/fileservice/service.go | 11 +- .../internal/fileservice/tree_test.go | 103 +++++++++++++++--- 2 files changed, 98 insertions(+), 16 deletions(-) diff --git a/apps/sandboxio/internal/fileservice/service.go b/apps/sandboxio/internal/fileservice/service.go index ec6589ca0..4362db7e8 100644 --- a/apps/sandboxio/internal/fileservice/service.go +++ b/apps/sandboxio/internal/fileservice/service.go @@ -623,9 +623,16 @@ func (st *state) release(id sandboxfs.HandleID, dir bool) error { st.mu.Unlock() return sandboxfs.NewErrnoFailure(sandboxfs.ErrnoBadDescriptor, sandboxwire.EffectNone, "wrong handle kind") } - delete(st.handles, id) st.mu.Unlock() - return h.close() + // Keep the handle addressable until close joins its descriptor users. + // Concurrent releases must join that same close before proving absence. + err := h.close() + st.mu.Lock() + if st.handles[id] == h { + delete(st.handles, id) + } + st.mu.Unlock() + return err } // attr converts a stat. Ino combines the device with the inode number, as diff --git a/apps/sandboxio/internal/fileservice/tree_test.go b/apps/sandboxio/internal/fileservice/tree_test.go index d90770a5b..bccfb3ffa 100644 --- a/apps/sandboxio/internal/fileservice/tree_test.go +++ b/apps/sandboxio/internal/fileservice/tree_test.go @@ -409,20 +409,7 @@ func TestOpenTreeCloseWaitsForDescriptorRead(t *testing.T) { closed <- f.svc.Close() } }() - // Wait until close admission is observable, without relying on sleeps. - deadline := time.Now().Add(5 * time.Second) - for { - st.mu.Lock() - _, present := st.handles[req.Handle] - st.mu.Unlock() - if !present { - break - } - if time.Now().After(deadline) { - t.Fatal("close not entered") - } - time.Sleep(time.Millisecond) - } + waitTreeClose(t, h) bound, _ := sandboxfs.TreeSizeBound(req, f.svc.caps) treeBudget(t, f.svc, 1, bound) select { @@ -623,3 +610,91 @@ func TestOpenTreeEnforcesProcessPermissions(t *testing.T) { } }) } + +// A pending close writer prevents new descriptor readers from entering. +func waitTreeClose(t *testing.T, h *handle) { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for h.useMu.TryRLock() { + h.useMu.RUnlock() + if time.Now().After(deadline) { + t.Fatal("close not entered") + } + time.Sleep(time.Millisecond) + } +} + +func TestOpenTreeRepeatedReleaseJoinsClosingHandle(t *testing.T) { + for _, next := range []string{"release", "detach"} { + t.Run(next, func(t *testing.T) { + f := newFixture(t) + q := treeRequest(f) + if _, err := f.svc.OpenTree(t.Context(), f.att, &q); err != nil { + t.Fatal(err) + } + st := f.svc.atts[f.att.ID] + h, err := st.handle(q.Handle) + if err != nil { + t.Fatal(err) + } + entered, resume := make(chan struct{}), make(chan struct{}) + unblock := sync.OnceFunc(func() { close(resume) }) + defer unblock() + reading := make(chan error, 1) + go func() { + reading <- h.use(func(fd int) error { + close(entered) + <-resume + var b [1]byte + _, err := unix.Pread(fd, b[:], 0) + return err + }) + }() + <-entered + first := make(chan error, 1) + go func() { + _, err := f.svc.Release(t.Context(), f.att, &sandboxfs.ReleaseRequest{Handle: q.Handle}) + first <- err + }() + waitTreeClose(t, h) + second := make(chan error, 1) + secondStarted := make(chan struct{}) + go func() { + close(secondStarted) + if next == "release" { + _, err := f.svc.Release(t.Context(), f.att, &sandboxfs.ReleaseRequest{Handle: q.Handle}) + second <- err + } else { + _, err := f.svc.Detach(t.Context(), f.att, &sandboxfs.DetachRequest{}) + second <- err + } + }() + <-secondStarted + select { + case err := <-second: + t.Fatalf("second %s returned before backing released: %v", next, err) + case <-time.After(20 * time.Millisecond): + } + bound, _ := sandboxfs.TreeSizeBound(q, f.svc.caps) + treeBudget(t, f.svc, 1, bound) + unblock() + if err := <-reading; err != nil { + t.Fatal(err) + } + if err := <-first; err != nil { + t.Fatal(err) + } + if err := <-second; err != nil { + if next != "release" { + t.Fatal(err) + } + wantFailure(t, err, sandboxfs.CodeStaleHandle, 0, none) + } + treeBudget(t, f.svc, 0, 0) + if next == "release" { + _, err := f.svc.Release(t.Context(), f.att, &sandboxfs.ReleaseRequest{Handle: q.Handle}) + wantFailure(t, err, sandboxfs.CodeStaleHandle, 0, none) + } + }) + } +}