Cross-module callers get an opaque ref plus rotating lease tokens so stale writers fail closed, and file I/O is refused inside a unit of work. Co-authored-by: Cursor <cursoragent@cursor.com>
220 lines
5.2 KiB
Go
220 lines
5.2 KiB
Go
package workspace
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"syscall"
|
|
|
|
"chat-agent-system/internal/app/uow"
|
|
)
|
|
|
|
// Capability is the only module allowed to turn a WorkspaceRef into bytes on disk.
|
|
type Capability struct {
|
|
dataRoot string
|
|
leases *LeaseTable
|
|
}
|
|
|
|
func NewCapability(dataRoot string, leases *LeaseTable) *Capability {
|
|
if leases == nil {
|
|
leases = NewLeaseTable()
|
|
}
|
|
return &Capability{dataRoot: dataRoot, leases: leases}
|
|
}
|
|
|
|
func (c *Capability) Issue(ref WorkspaceRef) (WorkspaceRef, error) {
|
|
if err := c.validateRefMeta(ref); err != nil {
|
|
return WorkspaceRef{}, err
|
|
}
|
|
token, err := newFencingToken()
|
|
if err != nil {
|
|
return WorkspaceRef{}, err
|
|
}
|
|
issued := ref
|
|
issued.SchemaVersion = SchemaVersion
|
|
issued.FencingToken = token
|
|
c.leases.Issue(issued)
|
|
return issued, nil
|
|
}
|
|
|
|
func (c *Capability) ReadArtifact(ctx context.Context, ref WorkspaceRef, key string) ([]byte, error) {
|
|
if err := c.guard(ctx, ref, false); err != nil {
|
|
return nil, err
|
|
}
|
|
path, err := c.resolveKey(ref, key)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
resolved, err := c.enforceRealpath(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return os.ReadFile(resolved)
|
|
}
|
|
|
|
func (c *Capability) WriteArtifact(ctx context.Context, ref WorkspaceRef, key string, body []byte) error {
|
|
if err := c.guard(ctx, ref, true); err != nil {
|
|
return err
|
|
}
|
|
path, err := c.resolveKey(ref, key)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
return err
|
|
}
|
|
tmp := path + ".tmp-" + ref.FencingToken[:8]
|
|
if err := os.WriteFile(tmp, body, 0o644); err != nil {
|
|
return err
|
|
}
|
|
if _, err := c.enforceRealpath(tmp); err != nil {
|
|
_ = os.Remove(tmp)
|
|
return err
|
|
}
|
|
if err := os.Rename(tmp, path); err != nil {
|
|
_ = os.Remove(tmp)
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *Capability) CommitProjection(ctx context.Context, ref WorkspaceRef, key, digest string) error {
|
|
if err := c.guard(ctx, ref, true); err != nil {
|
|
return err
|
|
}
|
|
body, err := c.ReadArtifact(ctx, ref, key)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
sum := sha256.Sum256(body)
|
|
got := hex.EncodeToString(sum[:])
|
|
if digest != "" && digest != got {
|
|
return fmt.Errorf("workspace: digest mismatch")
|
|
}
|
|
marker := []byte(got + "\ncomplete\n")
|
|
return c.WriteArtifact(ctx, ref, key+".complete", marker)
|
|
}
|
|
|
|
func (c *Capability) guard(ctx context.Context, ref WorkspaceRef, write bool) error {
|
|
if ctx != nil {
|
|
if _, ok := uow.ScopeFrom(ctx); ok {
|
|
return ErrIOInTransaction
|
|
}
|
|
}
|
|
if err := c.validateRefMeta(ref); err != nil {
|
|
return err
|
|
}
|
|
if write {
|
|
return c.leases.Assert(ref)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *Capability) validateRefMeta(ref WorkspaceRef) error {
|
|
if strings.TrimSpace(ref.ID) == "" {
|
|
return ErrInvalidKey
|
|
}
|
|
if ref.SchemaVersion != 0 && ref.SchemaVersion != SchemaVersion {
|
|
return ErrUnknownSchema
|
|
}
|
|
if ref.ProjectScope < 0 {
|
|
return ErrCrossProject
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *Capability) resolveKey(ref WorkspaceRef, key string) (string, error) {
|
|
if err := validateArtifactKey(key); err != nil {
|
|
return "", err
|
|
}
|
|
root := filepath.Join(c.dataRoot, "workspaces", ref.ID)
|
|
joined := filepath.Join(root, filepath.FromSlash(key))
|
|
absRoot, err := filepath.Abs(root)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
absJoin, err := filepath.Abs(joined)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
rel, err := filepath.Rel(absRoot, absJoin)
|
|
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
|
|
return "", ErrPathEscape
|
|
}
|
|
return absJoin, nil
|
|
}
|
|
|
|
func (c *Capability) enforceRealpath(path string) (string, error) {
|
|
info, err := os.Lstat(path)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return path, nil
|
|
}
|
|
return "", err
|
|
}
|
|
if info.Mode()&os.ModeSymlink != 0 {
|
|
return "", ErrPathEscape
|
|
}
|
|
if info.Mode()&os.ModeNamedPipe != 0 {
|
|
return "", ErrPathEscape
|
|
}
|
|
resolved, err := filepath.EvalSymlinks(filepath.Dir(path))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
full := filepath.Join(resolved, filepath.Base(path))
|
|
root, err := filepath.Abs(c.dataRoot)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
rel, err := filepath.Rel(root, full)
|
|
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
|
|
return "", ErrPathEscape
|
|
}
|
|
if st, ok := info.Sys().(*syscall.Stat_t); ok && st.Nlink > 1 && !info.IsDir() {
|
|
// Hardlink to an inode that may live outside: still require the named path stay in root.
|
|
if !strings.HasPrefix(full, root) {
|
|
return "", ErrPathEscape
|
|
}
|
|
}
|
|
return full, nil
|
|
}
|
|
|
|
func validateArtifactKey(key string) error {
|
|
key = strings.TrimSpace(key)
|
|
if key == "" {
|
|
return ErrInvalidKey
|
|
}
|
|
if strings.ContainsRune(key, 0) {
|
|
return ErrInvalidKey
|
|
}
|
|
if filepath.IsAbs(key) || strings.HasPrefix(key, "/") || strings.HasPrefix(key, `\`) {
|
|
return ErrInvalidKey
|
|
}
|
|
lower := strings.ToLower(key)
|
|
if strings.Contains(lower, "%2e%2e") || strings.Contains(lower, "%2f") || strings.Contains(lower, "%5c") {
|
|
return ErrInvalidKey
|
|
}
|
|
cleaned := filepath.ToSlash(key)
|
|
for _, part := range strings.Split(cleaned, "/") {
|
|
if part == "" || part == "." || part == ".." {
|
|
return ErrInvalidKey
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func newFencingToken() (string, error) {
|
|
var b [16]byte
|
|
if _, err := io.ReadFull(rand.Reader, b[:]); err != nil {
|
|
return "", err
|
|
}
|
|
return hex.EncodeToString(b[:]), nil
|
|
}
|