Feat v0.8.1 workspace module (#68)
All checks were successful
CI/CD / detect-changes (push) Successful in 4s
CI/CD / test-frontend (push) Has been skipped
CI/CD / test-runners (push) Has been skipped
CI/CD / e2e-smoke (push) Has been skipped
CI/CD / test-go-pg (push) Successful in 2m49s
CI/CD / test-sqlite (push) Successful in 2m54s
CI/CD / build-and-deploy (push) Successful in 36s
All checks were successful
CI/CD / detect-changes (push) Successful in 4s
CI/CD / test-frontend (push) Has been skipped
CI/CD / test-runners (push) Has been skipped
CI/CD / e2e-smoke (push) Has been skipped
CI/CD / test-go-pg (push) Successful in 2m49s
CI/CD / test-sqlite (push) Successful in 2m54s
CI/CD / build-and-deploy (push) Successful in 36s
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit was merged in pull request #68.
This commit is contained in:
261
server/sandbox/workspace_module.go
Normal file
261
server/sandbox/workspace_module.go
Normal file
@@ -0,0 +1,261 @@
|
||||
// Package sandbox — workspace_module.go
|
||||
//
|
||||
// Permission: workspace.manage
|
||||
//
|
||||
// Starlark API:
|
||||
// path = workspace.create(name) → absolute path string (idempotent)
|
||||
// path = workspace.path(name) → path string or None
|
||||
// names = workspace.list() → list of workspace name strings
|
||||
// workspace.delete(name) → True (idempotent, destructive)
|
||||
// bytes = workspace.usage(name) → int (disk usage in bytes)
|
||||
//
|
||||
// Managed disk directories for extensions that need a real filesystem
|
||||
// (git, compilers, media tools). All directories are scoped to
|
||||
// {WORKSPACE_ROOT}/{packageID}/{name}/ — extensions cannot access each
|
||||
// other's workspaces. Optional quota enforcement via WORKSPACE_QUOTA_MB.
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.starlark.net/starlark"
|
||||
"go.starlark.net/starlarkstruct"
|
||||
)
|
||||
|
||||
var workspaceNameRe = regexp.MustCompile(`^[a-z][a-z0-9_]{0,62}$`)
|
||||
|
||||
// WorkspaceModuleConfig holds the configuration for the workspace module.
|
||||
type WorkspaceModuleConfig struct {
|
||||
PackageID string
|
||||
WorkspaceRoot string
|
||||
QuotaMB int // 0 = unlimited
|
||||
}
|
||||
|
||||
// BuildWorkspaceModule creates the "workspace" module.
|
||||
func BuildWorkspaceModule(ctx context.Context, cfg WorkspaceModuleConfig) *starlarkstruct.Module {
|
||||
fns := starlark.StringDict{
|
||||
"create": starlark.NewBuiltin("workspace.create", workspaceCreate(ctx, cfg)),
|
||||
"path": starlark.NewBuiltin("workspace.path", workspacePath(ctx, cfg)),
|
||||
"list": starlark.NewBuiltin("workspace.list", workspaceList(ctx, cfg)),
|
||||
"delete": starlark.NewBuiltin("workspace.delete", workspaceDelete(ctx, cfg)),
|
||||
"usage": starlark.NewBuiltin("workspace.usage", workspaceUsage(ctx, cfg)),
|
||||
}
|
||||
return MakeModule("workspace", fns)
|
||||
}
|
||||
|
||||
// ── Builtins ──────────────────────────────────
|
||||
|
||||
func workspaceCreate(ctx context.Context, cfg WorkspaceModuleConfig) func(*starlark.Thread, *starlark.Builtin, starlark.Tuple, []starlark.Tuple) (starlark.Value, error) {
|
||||
return func(thread *starlark.Thread, b *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {
|
||||
var name string
|
||||
if err := starlark.UnpackPositionalArgs(b.Name(), args, kwargs, 1, &name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dir, err := cfg.safePath(name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("workspace.create: %w", err)
|
||||
}
|
||||
|
||||
// Enforce quota before creating.
|
||||
if cfg.QuotaMB > 0 {
|
||||
quotaCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
used, err := dirUsage(quotaCtx, cfg.packageDir())
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("workspace.create: quota check: %w", err)
|
||||
}
|
||||
limit := int64(cfg.QuotaMB) * 1024 * 1024
|
||||
if used >= limit {
|
||||
return nil, fmt.Errorf("workspace.create: quota exceeded (%d bytes used, limit %d)", used, limit)
|
||||
}
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("workspace.create: %w", err)
|
||||
}
|
||||
|
||||
return starlark.String(dir), nil
|
||||
}
|
||||
}
|
||||
|
||||
func workspacePath(ctx context.Context, cfg WorkspaceModuleConfig) func(*starlark.Thread, *starlark.Builtin, starlark.Tuple, []starlark.Tuple) (starlark.Value, error) {
|
||||
return func(thread *starlark.Thread, b *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {
|
||||
var name string
|
||||
if err := starlark.UnpackPositionalArgs(b.Name(), args, kwargs, 1, &name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dir, err := cfg.safePath(name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("workspace.path: %w", err)
|
||||
}
|
||||
|
||||
info, err := os.Stat(dir)
|
||||
if err != nil || !info.IsDir() {
|
||||
return starlark.None, nil
|
||||
}
|
||||
|
||||
return starlark.String(dir), nil
|
||||
}
|
||||
}
|
||||
|
||||
func workspaceList(ctx context.Context, cfg WorkspaceModuleConfig) func(*starlark.Thread, *starlark.Builtin, starlark.Tuple, []starlark.Tuple) (starlark.Value, error) {
|
||||
return func(thread *starlark.Thread, b *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {
|
||||
if err := starlark.UnpackPositionalArgs(b.Name(), args, kwargs, 0); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(cfg.packageDir())
|
||||
if os.IsNotExist(err) {
|
||||
return starlark.NewList(nil), nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("workspace.list: %w", err)
|
||||
}
|
||||
|
||||
var names []starlark.Value
|
||||
for _, e := range entries {
|
||||
if e.IsDir() && workspaceNameRe.MatchString(e.Name()) {
|
||||
names = append(names, starlark.String(e.Name()))
|
||||
}
|
||||
}
|
||||
|
||||
return starlark.NewList(names), nil
|
||||
}
|
||||
}
|
||||
|
||||
func workspaceDelete(ctx context.Context, cfg WorkspaceModuleConfig) func(*starlark.Thread, *starlark.Builtin, starlark.Tuple, []starlark.Tuple) (starlark.Value, error) {
|
||||
return func(thread *starlark.Thread, b *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {
|
||||
var name string
|
||||
if err := starlark.UnpackPositionalArgs(b.Name(), args, kwargs, 1, &name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dir, err := cfg.safePath(name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("workspace.delete: %w", err)
|
||||
}
|
||||
|
||||
if err := os.RemoveAll(dir); err != nil {
|
||||
return nil, fmt.Errorf("workspace.delete: %w", err)
|
||||
}
|
||||
|
||||
return starlark.True, nil
|
||||
}
|
||||
}
|
||||
|
||||
func workspaceUsage(ctx context.Context, cfg WorkspaceModuleConfig) func(*starlark.Thread, *starlark.Builtin, starlark.Tuple, []starlark.Tuple) (starlark.Value, error) {
|
||||
return func(thread *starlark.Thread, b *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {
|
||||
var name string
|
||||
if err := starlark.UnpackPositionalArgs(b.Name(), args, kwargs, 1, &name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dir, err := cfg.safePath(name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("workspace.usage: %w", err)
|
||||
}
|
||||
|
||||
info, statErr := os.Stat(dir)
|
||||
if statErr != nil || !info.IsDir() {
|
||||
return nil, fmt.Errorf("workspace.usage: workspace %q does not exist", name)
|
||||
}
|
||||
|
||||
usageCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
total, err := dirUsage(usageCtx, dir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("workspace.usage: %w", err)
|
||||
}
|
||||
|
||||
return starlark.MakeInt64(total), nil
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────
|
||||
|
||||
// packageDir returns the root directory for this extension's workspaces.
|
||||
func (cfg WorkspaceModuleConfig) packageDir() string {
|
||||
return filepath.Join(cfg.WorkspaceRoot, cfg.PackageID)
|
||||
}
|
||||
|
||||
// safePath validates a workspace name and returns the absolute directory
|
||||
// path, guarding against path traversal and symlink escape.
|
||||
func (cfg WorkspaceModuleConfig) safePath(name string) (string, error) {
|
||||
if err := validateWorkspaceName(name); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
dir := filepath.Join(cfg.WorkspaceRoot, cfg.PackageID, name)
|
||||
cleaned := filepath.Clean(dir)
|
||||
|
||||
// Ensure the cleaned path is under the package directory.
|
||||
pkgDir := filepath.Clean(cfg.packageDir())
|
||||
if !strings.HasPrefix(cleaned, pkgDir+string(filepath.Separator)) {
|
||||
return "", fmt.Errorf("path escapes workspace root")
|
||||
}
|
||||
|
||||
// If the parent directory exists, resolve symlinks to detect escape.
|
||||
parent := filepath.Dir(cleaned)
|
||||
if _, err := os.Stat(parent); err == nil {
|
||||
resolved, err := filepath.EvalSymlinks(parent)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("symlink resolution failed: %w", err)
|
||||
}
|
||||
resolvedRoot := filepath.Clean(cfg.WorkspaceRoot)
|
||||
if realRoot, err := filepath.EvalSymlinks(resolvedRoot); err == nil {
|
||||
resolvedRoot = realRoot
|
||||
}
|
||||
if !strings.HasPrefix(resolved, resolvedRoot) {
|
||||
return "", fmt.Errorf("path escapes workspace root via symlink")
|
||||
}
|
||||
}
|
||||
|
||||
return cleaned, nil
|
||||
}
|
||||
|
||||
// validateWorkspaceName checks that a workspace name matches the allowed pattern.
|
||||
func validateWorkspaceName(name string) error {
|
||||
if name == "" {
|
||||
return fmt.Errorf("workspace name is required")
|
||||
}
|
||||
if !workspaceNameRe.MatchString(name) {
|
||||
return fmt.Errorf("workspace name %q must match %s", name, workspaceNameRe.String())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// dirUsage walks a directory tree and returns the total size in bytes.
|
||||
// Respects context cancellation for timeout enforcement.
|
||||
func dirUsage(ctx context.Context, root string) (int64, error) {
|
||||
var total int64
|
||||
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
if !d.IsDir() {
|
||||
info, err := d.Info()
|
||||
if err != nil {
|
||||
return nil // skip unreadable files
|
||||
}
|
||||
total += info.Size()
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if os.IsNotExist(err) {
|
||||
return 0, nil
|
||||
}
|
||||
return total, err
|
||||
}
|
||||
Reference in New Issue
Block a user