승인된 execution preset을 Edge 조정 경계와 Node workspace/tool 실행 경계로 연결해 단일 요청 수명주기와 관측 계약을 일관되게 처리한다.
322 lines
9.4 KiB
Go
322 lines
9.4 KiB
Go
package workspace
|
|
|
|
import (
|
|
"container/heap"
|
|
"crypto/rand"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path"
|
|
"sort"
|
|
"time"
|
|
|
|
iop "iop/proto/gen/iop"
|
|
)
|
|
|
|
const maxListEntries = 1024
|
|
const listBatchSize = 128
|
|
|
|
type listMaxHeap []string
|
|
|
|
func (h listMaxHeap) Len() int { return len(h) }
|
|
func (h listMaxHeap) Less(i, j int) bool { return h[i] > h[j] }
|
|
func (h listMaxHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] }
|
|
func (h *listMaxHeap) Push(value any) { *h = append(*h, value.(string)) }
|
|
func (h *listMaxHeap) Pop() any {
|
|
old := *h
|
|
last := old[len(old)-1]
|
|
*h = old[:len(old)-1]
|
|
return last
|
|
}
|
|
|
|
// Result is intentionally content-free on failure. The Node handler maps it to
|
|
// the typed wire response without returning filesystem paths or OS errors.
|
|
type Result struct {
|
|
Status iop.WorkspaceStatus
|
|
Code iop.WorkspaceErrorCode
|
|
Content []byte
|
|
Entries []string
|
|
Stdout []byte
|
|
Stderr []byte
|
|
ExitCode int32
|
|
Truncated bool
|
|
DurationMS int64
|
|
}
|
|
|
|
func (r *Runtime) Read(requestID, relativePath string) (result Result) {
|
|
startedAt := time.Now()
|
|
var correlation string
|
|
defer func() {
|
|
result.DurationMS = time.Since(startedAt).Milliseconds()
|
|
r.observeTool(correlation, iop.WorkspaceOperation_WORKSPACE_OPERATION_READ, result)
|
|
}()
|
|
return r.withRequest(requestID, func(req *Request) Result {
|
|
correlation = req.correlation
|
|
if _, result := r.allows(requestID, iop.WorkspaceOperation_WORKSPACE_OPERATION_READ); result.Status != iop.WorkspaceStatus_WORKSPACE_STATUS_SUCCESS {
|
|
return result
|
|
}
|
|
name, err := userPath(relativePath)
|
|
if err != nil {
|
|
return failureFor(err)
|
|
}
|
|
info, err := checkedExisting(req.entry, name, false, false)
|
|
if err != nil || !info.Mode().IsRegular() {
|
|
if err == nil {
|
|
err = errUnsafePath
|
|
}
|
|
return failureFor(err)
|
|
}
|
|
file, err := req.entry.root.Open(name)
|
|
if err != nil {
|
|
return failureFor(err)
|
|
}
|
|
defer file.Close()
|
|
opened, err := file.Stat()
|
|
if err != nil || !opened.Mode().IsRegular() {
|
|
return failureFor(errUnsafePath)
|
|
}
|
|
if device, _, ok := fileIdentity(opened); !ok || device != req.entry.device {
|
|
return failureFor(errUnsafePath)
|
|
}
|
|
data, err := io.ReadAll(io.LimitReader(file, req.maxRead+1))
|
|
if err != nil {
|
|
return failureFor(err)
|
|
}
|
|
if int64(len(data)) > req.maxRead {
|
|
return Result{Status: iop.WorkspaceStatus_WORKSPACE_STATUS_SUCCESS, Content: data[:req.maxRead], Truncated: true}
|
|
}
|
|
return Result{Status: iop.WorkspaceStatus_WORKSPACE_STATUS_SUCCESS, Content: data}
|
|
})
|
|
}
|
|
|
|
func (r *Runtime) List(requestID, relativePath string) (result Result) {
|
|
startedAt := time.Now()
|
|
var correlation string
|
|
defer func() {
|
|
result.DurationMS = time.Since(startedAt).Milliseconds()
|
|
r.observeTool(correlation, iop.WorkspaceOperation_WORKSPACE_OPERATION_LIST, result)
|
|
}()
|
|
return r.withRequest(requestID, func(req *Request) Result {
|
|
correlation = req.correlation
|
|
if _, result := r.allows(requestID, iop.WorkspaceOperation_WORKSPACE_OPERATION_LIST); result.Status != iop.WorkspaceStatus_WORKSPACE_STATUS_SUCCESS {
|
|
return result
|
|
}
|
|
name, err := userPath(relativePath)
|
|
if err != nil {
|
|
return failureFor(err)
|
|
}
|
|
if _, err := checkedExisting(req.entry, name, true, false); err != nil {
|
|
return failureFor(err)
|
|
}
|
|
file, err := req.entry.root.Open(name)
|
|
if err != nil {
|
|
return failureFor(err)
|
|
}
|
|
defer file.Close()
|
|
opened, err := file.Stat()
|
|
if err != nil || !opened.IsDir() {
|
|
return failureFor(errUnsafePath)
|
|
}
|
|
if device, _, ok := fileIdentity(opened); !ok || device != req.entry.device {
|
|
return failureFor(errUnsafePath)
|
|
}
|
|
retained := make(listMaxHeap, 0, maxListEntries)
|
|
heap.Init(&retained)
|
|
truncated := false
|
|
for {
|
|
batch, readErr := file.ReadDir(listBatchSize)
|
|
for _, item := range batch {
|
|
candidate := item.Name()
|
|
if name == "." && candidate == ".iop" {
|
|
continue
|
|
}
|
|
if retained.Len() < maxListEntries {
|
|
heap.Push(&retained, candidate)
|
|
continue
|
|
}
|
|
truncated = true
|
|
if candidate < retained[0] {
|
|
retained[0] = candidate
|
|
heap.Fix(&retained, 0)
|
|
}
|
|
}
|
|
if errors.Is(readErr, io.EOF) {
|
|
break
|
|
}
|
|
if readErr != nil {
|
|
return failureFor(readErr)
|
|
}
|
|
}
|
|
entries := []string(retained)
|
|
sort.Strings(entries)
|
|
result := Result{Status: iop.WorkspaceStatus_WORKSPACE_STATUS_SUCCESS, Truncated: truncated}
|
|
var bytes int64
|
|
for _, item := range entries {
|
|
child := item
|
|
if name != "." {
|
|
child = path.Join(name, child)
|
|
}
|
|
info, err := checkedExisting(req.entry, child, false, false)
|
|
if err != nil {
|
|
return failureFor(err)
|
|
}
|
|
if !info.IsDir() && !info.Mode().IsRegular() {
|
|
return failureFor(errUnsafePath)
|
|
}
|
|
encoded := item + "\t" + entryType(info)
|
|
if bytes+int64(len(encoded)) > req.maxOutput {
|
|
result.Truncated = true
|
|
break
|
|
}
|
|
bytes += int64(len(encoded))
|
|
result.Entries = append(result.Entries, encoded)
|
|
}
|
|
return result
|
|
})
|
|
}
|
|
|
|
func (r *Runtime) Write(requestID, relativePath string, content []byte) (result Result) {
|
|
startedAt := time.Now()
|
|
var correlation string
|
|
defer func() {
|
|
result.DurationMS = time.Since(startedAt).Milliseconds()
|
|
r.observeTool(correlation, iop.WorkspaceOperation_WORKSPACE_OPERATION_WRITE, result)
|
|
}()
|
|
return r.withRequest(requestID, func(req *Request) Result {
|
|
correlation = req.correlation
|
|
if _, result := r.allows(requestID, iop.WorkspaceOperation_WORKSPACE_OPERATION_WRITE); result.Status != iop.WorkspaceStatus_WORKSPACE_STATUS_SUCCESS {
|
|
return result
|
|
}
|
|
if int64(len(content)) > req.maxWrite {
|
|
return failureFor(errInvalidPath)
|
|
}
|
|
name, err := userPath(relativePath)
|
|
if err != nil || name == "." {
|
|
if err == nil {
|
|
err = errInvalidPath
|
|
}
|
|
return failureFor(err)
|
|
}
|
|
parent, base, err := openOrCreateParentNoFollow(req.entry, name)
|
|
if err != nil {
|
|
return failureFor(err)
|
|
}
|
|
defer parent.close()
|
|
initialTarget, err := parent.targetIdentity(base)
|
|
if err != nil || initialTarget.exists && (!initialTarget.mode.IsRegular() || initialTarget.device != req.entry.device) {
|
|
return failureFor(errUnsafePath)
|
|
}
|
|
tmpBase, err := randomTempBase()
|
|
if err != nil {
|
|
return failureFor(err)
|
|
}
|
|
file, err := parent.createTemp(tmpBase)
|
|
if err != nil {
|
|
return failureFor(err)
|
|
}
|
|
ok := false
|
|
defer func() {
|
|
if !ok {
|
|
_ = parent.remove(tmpBase)
|
|
}
|
|
}()
|
|
if _, err := file.Write(content); err != nil {
|
|
_ = file.Close()
|
|
return failureFor(err)
|
|
}
|
|
if err := file.Sync(); err != nil {
|
|
_ = file.Close()
|
|
return failureFor(err)
|
|
}
|
|
if err := file.Close(); err != nil {
|
|
return failureFor(err)
|
|
}
|
|
if req.entry.beforeRename != nil {
|
|
if err := req.entry.beforeRename(); err != nil {
|
|
return failureFor(err)
|
|
}
|
|
}
|
|
if err := parent.revalidate(); err != nil {
|
|
return failureFor(err)
|
|
}
|
|
currentTarget, err := parent.targetIdentity(base)
|
|
if err != nil || currentTarget != initialTarget {
|
|
return failureFor(errUnsafePath)
|
|
}
|
|
if err := parent.rename(tmpBase, base); err != nil {
|
|
return failureFor(err)
|
|
}
|
|
ok = true
|
|
// The atomic replacement is already committed. Directory sync is best
|
|
// effort because reporting a post-effect failure would violate the
|
|
// executor's failure-preserves-target contract.
|
|
_ = parent.sync()
|
|
return Result{Status: iop.WorkspaceStatus_WORKSPACE_STATUS_SUCCESS}
|
|
})
|
|
}
|
|
|
|
func (r *Runtime) Delete(requestID, relativePath string) (result Result) {
|
|
startedAt := time.Now()
|
|
var correlation string
|
|
defer func() {
|
|
result.DurationMS = time.Since(startedAt).Milliseconds()
|
|
r.observeTool(correlation, iop.WorkspaceOperation_WORKSPACE_OPERATION_DELETE, result)
|
|
}()
|
|
return r.withRequest(requestID, func(req *Request) Result {
|
|
correlation = req.correlation
|
|
if _, result := r.allows(requestID, iop.WorkspaceOperation_WORKSPACE_OPERATION_DELETE); result.Status != iop.WorkspaceStatus_WORKSPACE_STATUS_SUCCESS {
|
|
return result
|
|
}
|
|
name, err := userPath(relativePath)
|
|
if err != nil || name == "." {
|
|
if err == nil {
|
|
err = errInvalidPath
|
|
}
|
|
return failureFor(err)
|
|
}
|
|
info, err := checkedExisting(req.entry, name, false, true)
|
|
if err != nil {
|
|
return failureFor(err)
|
|
}
|
|
if info.Mode()&os.ModeSymlink == 0 && !info.Mode().IsRegular() && !info.IsDir() {
|
|
return failureFor(errUnsafePath)
|
|
}
|
|
if err := req.entry.root.Remove(name); err != nil {
|
|
return failureFor(err)
|
|
}
|
|
return Result{Status: iop.WorkspaceStatus_WORKSPACE_STATUS_SUCCESS}
|
|
})
|
|
}
|
|
|
|
func entryType(info os.FileInfo) string {
|
|
switch {
|
|
case info.IsDir():
|
|
return "dir"
|
|
case info.Mode().IsRegular():
|
|
return "file"
|
|
default:
|
|
return "other"
|
|
}
|
|
}
|
|
|
|
func randomTempBase() (string, error) {
|
|
var token [12]byte
|
|
if _, err := rand.Read(token[:]); err != nil {
|
|
return "", err
|
|
}
|
|
return fmt.Sprintf(".iop-write-%x", token), nil
|
|
}
|
|
|
|
func failureFor(err error) Result {
|
|
if errors.Is(err, errNotFound) || errors.Is(err, os.ErrNotExist) {
|
|
return Result{Status: iop.WorkspaceStatus_WORKSPACE_STATUS_ERROR, Code: iop.WorkspaceErrorCode_WORKSPACE_ERROR_CODE_NOT_FOUND}
|
|
}
|
|
if errors.Is(err, ErrClosed) {
|
|
return Result{Status: iop.WorkspaceStatus_WORKSPACE_STATUS_UNSUPPORTED, Code: iop.WorkspaceErrorCode_WORKSPACE_ERROR_CODE_NOT_READY}
|
|
}
|
|
if errors.Is(err, ErrInvalidRequest) || errors.Is(err, ErrRequestConflict) || errors.Is(err, ErrUnknownWorkspace) || errors.Is(err, errInvalidPath) || errors.Is(err, errReservedPath) {
|
|
return Result{Status: iop.WorkspaceStatus_WORKSPACE_STATUS_ERROR, Code: iop.WorkspaceErrorCode_WORKSPACE_ERROR_CODE_INVALID_REQUEST}
|
|
}
|
|
return Result{Status: iop.WorkspaceStatus_WORKSPACE_STATUS_ERROR, Code: iop.WorkspaceErrorCode_WORKSPACE_ERROR_CODE_INTERNAL}
|
|
}
|