iop/apps/node/internal/workspace/file_executor_test.go
toki dc9a9a8c59 feat(agent): 단일 요청 Agent 실행 경계를 구현한다
승인된 execution preset을 Edge 조정 경계와 Node workspace/tool 실행 경계로 연결해 단일 요청 수명주기와 관측 계약을 일관되게 처리한다.
2026-08-07 07:03:55 +09:00

304 lines
10 KiB
Go

package workspace
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"golang.org/x/sys/unix"
iop "iop/proto/gen/iop"
)
func openedRuntime(t *testing.T) (*Runtime, string) {
t.Helper()
root := t.TempDir()
rt, err := NewRuntime([]*iop.WorkspaceConfig{testWorkspaceConfig(root)}, "darwin", nil)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = rt.Close() })
if _, err := rt.Open(testRequestAuthority("request-1")); err != nil {
t.Fatal(err)
}
return rt, root
}
func TestFileExecutorReadListWriteDelete(t *testing.T) {
rt, root := openedRuntime(t)
if err := os.WriteFile(filepath.Join(root, "input.txt"), []byte("hello"), 0600); err != nil {
t.Fatal(err)
}
read := rt.Read("request-1", "input.txt")
if read.Status != iop.WorkspaceStatus_WORKSPACE_STATUS_SUCCESS || string(read.Content) != "hello" {
t.Fatalf("read=%+v", read)
}
list := rt.List("request-1", ".")
if list.Status != iop.WorkspaceStatus_WORKSPACE_STATUS_SUCCESS || len(list.Entries) != 1 || list.Entries[0] != "input.txt\tfile" {
t.Fatalf("list=%+v", list)
}
if write := rt.Write("request-1", "nested/output.txt", []byte("written")); write.Status != iop.WorkspaceStatus_WORKSPACE_STATUS_SUCCESS {
t.Fatalf("write=%+v", write)
}
data, err := os.ReadFile(filepath.Join(root, "nested", "output.txt"))
if err != nil || string(data) != "written" {
t.Fatalf("output=%q err=%v", data, err)
}
if deleted := rt.Delete("request-1", "nested/output.txt"); deleted.Status != iop.WorkspaceStatus_WORKSPACE_STATUS_SUCCESS {
t.Fatalf("delete=%+v", deleted)
}
if _, err := os.Stat(filepath.Join(root, "nested", "output.txt")); !os.IsNotExist(err) {
t.Fatalf("deleted file remains: %v", err)
}
}
func TestFileExecutorRejectsReservedSymlinkAndBounds(t *testing.T) {
rt, root := openedRuntime(t)
outside := filepath.Join(t.TempDir(), "outside.txt")
if err := os.WriteFile(outside, []byte("outside"), 0600); err != nil {
t.Fatal(err)
}
if err := os.Symlink(outside, filepath.Join(root, "escape")); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(root, ".iop"), 0700); err != nil {
t.Fatal(err)
}
for _, target := range []string{".iop", ".iop/job/request-2/x", "escape"} {
if result := rt.Read("request-1", target); result.Status == iop.WorkspaceStatus_WORKSPACE_STATUS_SUCCESS {
t.Fatalf("read admitted %q: %+v", target, result)
}
}
if err := os.WriteFile(filepath.Join(root, "large"), []byte(strings.Repeat("x", 65)), 0600); err != nil {
t.Fatal(err)
}
if result := rt.Read("request-1", "large"); !result.Truncated || len(result.Content) != 64 {
t.Fatalf("bounded read=%+v", result)
}
if result := rt.Write("request-1", "too-large", []byte(strings.Repeat("x", 65))); result.Status == iop.WorkspaceStatus_WORKSPACE_STATUS_SUCCESS {
t.Fatalf("oversize write=%+v", result)
}
request, err := rt.Request("request-1")
if err != nil {
t.Fatal(err)
}
device := request.entry.device
request.entry.device++
if result := rt.Read("request-1", "large"); result.Status == iop.WorkspaceStatus_WORKSPACE_STATUS_SUCCESS {
t.Fatalf("cross-filesystem read=%+v", result)
}
request.entry.device = device
}
func TestFileExecutorWriteFailurePreservesTarget(t *testing.T) {
rt, root := openedRuntime(t)
target := filepath.Join(root, "target.txt")
if err := os.WriteFile(target, []byte("old"), 0600); err != nil {
t.Fatal(err)
}
if result := rt.Write("request-1", "target.txt", []byte(strings.Repeat("x", 65))); result.Status == iop.WorkspaceStatus_WORKSPACE_STATUS_SUCCESS {
t.Fatalf("write=%+v", result)
}
data, err := os.ReadFile(target)
if err != nil || string(data) != "old" {
t.Fatalf("target=%q err=%v", data, err)
}
request, err := rt.Request("request-1")
if err != nil {
t.Fatal(err)
}
request.entry.beforeRename = func() error { return errors.New("injected before rename") }
if result := rt.Write("request-1", "target.txt", []byte("new")); result.Status == iop.WorkspaceStatus_WORKSPACE_STATUS_SUCCESS {
t.Fatalf("injected write=%+v", result)
}
request.entry.beforeRename = nil
data, err = os.ReadFile(target)
if err != nil || string(data) != "old" {
t.Fatalf("target after injected failure=%q err=%v", data, err)
}
assertNoWriteTemps(t, root)
if result := rt.Delete("request-1", "."); result.Status == iop.WorkspaceStatus_WORKSPACE_STATUS_SUCCESS {
t.Fatalf("root delete=%+v", result)
}
if err := os.Mkdir(filepath.Join(root, "nonempty"), 0700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "nonempty", "child"), []byte("x"), 0600); err != nil {
t.Fatal(err)
}
if result := rt.Delete("request-1", "nonempty"); result.Status == iop.WorkspaceStatus_WORKSPACE_STATUS_SUCCESS {
t.Fatalf("recursive delete=%+v", result)
}
}
func TestFileExecutorWriteRejectsUnsafeParentsWithoutEffects(t *testing.T) {
rt, root := openedRuntime(t)
outside := t.TempDir()
if err := os.Symlink(outside, filepath.Join(root, "link")); err != nil {
t.Fatal(err)
}
if result := rt.Write("request-1", "link/new/output.txt", []byte("bad")); result.Status == iop.WorkspaceStatus_WORKSPACE_STATUS_SUCCESS {
t.Fatalf("symlink-parent write=%+v", result)
}
if _, err := os.Stat(filepath.Join(outside, "new")); !os.IsNotExist(err) {
t.Fatalf("rejected symlink write created outside parent: %v", err)
}
request, err := rt.Request("request-1")
if err != nil {
t.Fatal(err)
}
originalDevice := request.entry.device
request.entry.device++
if result := rt.Write("request-1", "mount-substitute/output.txt", []byte("bad")); result.Status == iop.WorkspaceStatus_WORKSPACE_STATUS_SUCCESS {
t.Fatalf("foreign-device write=%+v", result)
}
request.entry.device = originalDevice
if _, err := os.Stat(filepath.Join(root, "mount-substitute")); !os.IsNotExist(err) {
t.Fatalf("foreign-device rejection created parent: %v", err)
}
parent := filepath.Join(root, "parent")
if err := os.Mkdir(parent, 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(parent, "target.txt"), []byte("old"), 0o600); err != nil {
t.Fatal(err)
}
moved := filepath.Join(root, "parent-moved")
request.entry.beforeRename = func() error {
if err := os.Rename(parent, moved); err != nil {
return err
}
return os.Mkdir(parent, 0o700)
}
if result := rt.Write("request-1", "parent/target.txt", []byte("new")); result.Status == iop.WorkspaceStatus_WORKSPACE_STATUS_SUCCESS {
t.Fatalf("replaced-parent write=%+v", result)
}
request.entry.beforeRename = nil
data, err := os.ReadFile(filepath.Join(moved, "target.txt"))
if err != nil || string(data) != "old" {
t.Fatalf("moved target=%q err=%v", data, err)
}
entries, err := os.ReadDir(parent)
if err != nil || len(entries) != 0 {
t.Fatalf("replacement parent entries=%v err=%v", entries, err)
}
assertNoWriteTemps(t, root)
}
func TestFileExecutorBoundedDeterministicLargeList(t *testing.T) {
root := t.TempDir()
config := testWorkspaceConfig(root)
config.MaxOutputBytes = 256
rt, err := NewRuntime([]*iop.WorkspaceConfig{config}, "darwin", nil)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = rt.Close() })
authority := testRequestAuthority("request-1")
authority.MaxOutputBytes = 256
if _, err := rt.Open(authority); err != nil {
t.Fatal(err)
}
for index := 0; index < maxListEntries+200; index++ {
name := fmt.Sprintf("entry-%04d-with-bounded-name", index)
if err := os.WriteFile(filepath.Join(root, name), nil, 0o600); err != nil {
t.Fatal(err)
}
}
first := rt.List("request-1", ".")
second := rt.List("request-1", ".")
if first.Status != iop.WorkspaceStatus_WORKSPACE_STATUS_SUCCESS || !first.Truncated || len(first.Entries) == 0 {
t.Fatalf("first list=%+v", first)
}
if strings.Join(first.Entries, "\n") != strings.Join(second.Entries, "\n") || first.Entries[0] != "entry-0000-with-bounded-name\tfile" {
t.Fatalf("list is not deterministic: first=%v second=%v", first.Entries, second.Entries)
}
}
func TestFileExecutorRejectsSpecialFileAndRunsParallelRequests(t *testing.T) {
rt, root := openedRuntime(t)
fifo := filepath.Join(root, "special")
if err := unix.Mkfifo(fifo, 0o600); err != nil {
t.Fatal(err)
}
for _, result := range []Result{
rt.Read("request-1", "special"),
rt.Write("request-1", "special", []byte("bad")),
rt.Delete("request-1", "special"),
} {
if result.Status == iop.WorkspaceStatus_WORKSPACE_STATUS_SUCCESS {
t.Fatalf("special file operation succeeded: %+v", result)
}
}
if err := os.Remove(fifo); err != nil {
t.Fatal(err)
}
requestIDs := make([]string, 16)
for index := 0; index < 16; index++ {
requestID := fmt.Sprintf("request-%d", index+2)
requestIDs[index] = requestID
if _, err := rt.Open(testRequestAuthority(requestID)); err != nil {
t.Fatal(err)
}
}
var group sync.WaitGroup
for index, requestID := range requestIDs {
group.Add(1)
go func(index int, requestID string) {
defer group.Done()
name := fmt.Sprintf("parallel/%02d.txt", index)
if result := rt.Write(requestID, name, []byte(requestID)); result.Status != iop.WorkspaceStatus_WORKSPACE_STATUS_SUCCESS {
t.Errorf("write %s=%+v", requestID, result)
return
}
if result := rt.Read(requestID, name); result.Status != iop.WorkspaceStatus_WORKSPACE_STATUS_SUCCESS || string(result.Content) != requestID {
t.Errorf("read %s=%+v", requestID, result)
}
}(index, requestID)
}
group.Wait()
for _, requestID := range requestIDs {
group.Add(1)
go func(requestID string) {
defer group.Done()
if result := rt.List(requestID, "parallel"); result.Status != iop.WorkspaceStatus_WORKSPACE_STATUS_SUCCESS {
t.Errorf("list %s=%+v", requestID, result)
}
}(requestID)
}
group.Wait()
for index, requestID := range requestIDs {
group.Add(1)
go func(index int, requestID string) {
defer group.Done()
name := fmt.Sprintf("parallel/%02d.txt", index)
if result := rt.Delete(requestID, name); result.Status != iop.WorkspaceStatus_WORKSPACE_STATUS_SUCCESS {
t.Errorf("delete %s=%+v", requestID, result)
}
}(index, requestID)
}
group.Wait()
}
func assertNoWriteTemps(t *testing.T, root string) {
t.Helper()
err := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error {
if err != nil {
return err
}
if strings.HasPrefix(entry.Name(), ".iop-write-") {
t.Fatalf("temporary write artifact remains: %s", path)
}
return nil
})
if err != nil {
t.Fatal(err)
}
}