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:
29
CHANGELOG.md
29
CHANGELOG.md
@@ -2,6 +2,35 @@
|
|||||||
|
|
||||||
All notable changes to Armature are documented here.
|
All notable changes to Armature are documented here.
|
||||||
|
|
||||||
|
## v0.8.1 — Workspace Module
|
||||||
|
|
||||||
|
New `workspace` sandbox module. Managed disk directories for extensions
|
||||||
|
that need a real filesystem (git, compilers, media tools).
|
||||||
|
|
||||||
|
**workspace module (permission: `workspace.manage`)**
|
||||||
|
|
||||||
|
- `workspace.create(name)` — create a workspace directory (idempotent). Returns absolute path. Enforces quota if `WORKSPACE_QUOTA_MB` > 0.
|
||||||
|
- `workspace.path(name)` — get the absolute path of an existing workspace. Returns None if not found.
|
||||||
|
- `workspace.list()` — list workspace names for this extension.
|
||||||
|
- `workspace.delete(name)` — recursively remove a workspace (idempotent).
|
||||||
|
- `workspace.usage(name)` — disk usage in bytes (10-second timeout on directory walk).
|
||||||
|
|
||||||
|
**Configuration**
|
||||||
|
|
||||||
|
- `WORKSPACE_ROOT` — mount point for extension workspaces (default `/data/workspaces`).
|
||||||
|
- `WORKSPACE_QUOTA_MB` — per-extension quota in MB (default `0` = unlimited).
|
||||||
|
|
||||||
|
**Internal**
|
||||||
|
|
||||||
|
- New file: `sandbox/workspace_module.go`.
|
||||||
|
- New permission constant: `ExtPermWorkspaceManage`.
|
||||||
|
- Config: `WorkspaceRoot`, `WorkspaceQuotaMB` fields.
|
||||||
|
- Runner wiring: `SetWorkspaceRoot()` setter, `buildModulesWithLibCtx` creates workspace module when permission granted.
|
||||||
|
- Startup: `main.go` creates workspace root directory if writable, graceful degradation if not.
|
||||||
|
- All directories scoped to `{WORKSPACE_ROOT}/{packageID}/{name}/`.
|
||||||
|
- Security: name regex (`^[a-z][a-z0-9_]{0,62}$`), `filepath.Clean` + prefix check, `filepath.EvalSymlinks` for symlink escape detection.
|
||||||
|
- 16 new tests (create, path, list, delete, usage, name validation, quota enforcement).
|
||||||
|
|
||||||
## v0.8.0 — Files Module
|
## v0.8.0 — Files Module
|
||||||
|
|
||||||
New `files` sandbox module. Bridges the existing ObjectStore (PVC/S3) into
|
New `files` sandbox module. Bridges the existing ObjectStore (PVC/S3) into
|
||||||
|
|||||||
14
ROADMAP.md
14
ROADMAP.md
@@ -1,6 +1,6 @@
|
|||||||
# Armature — Roadmap
|
# Armature — Roadmap
|
||||||
|
|
||||||
## Current: v0.8.0 — Files Module
|
## Current: v0.8.1 — Workspace Module
|
||||||
|
|
||||||
Self-hosted extensible platform kernel. Auth, identity, packages, Starlark
|
Self-hosted extensible platform kernel. Auth, identity, packages, Starlark
|
||||||
sandbox, storage, realtime, and ops are kernel primitives. Everything else
|
sandbox, storage, realtime, and ops are kernel primitives. Everything else
|
||||||
@@ -397,14 +397,18 @@ key namespacing (`ext/{packageID}/`). Permissions: `files.read`,
|
|||||||
New: `sandbox/files_module.go`. Modified: runner wiring, permission
|
New: `sandbox/files_module.go`. Modified: runner wiring, permission
|
||||||
constants. `ObjectStore` interface gained `List()` method. 16 new tests.
|
constants. `ObjectStore` interface gained `List()` method. 16 new tests.
|
||||||
|
|
||||||
**v0.8.1 — `workspace` Module**
|
**v0.8.1 — `workspace` Module** ✅
|
||||||
|
|
||||||
Managed disk directories for tools that need a real filesystem (git,
|
Managed disk directories for tools that need a real filesystem (git,
|
||||||
compilers, media tools). `workspace.create(name)` → scoped path.
|
compilers, media tools). Extension-scoped path namespacing
|
||||||
Permission: `workspace.manage`. Quota enforcement via `WORKSPACE_QUOTA_MB`.
|
(`{WORKSPACE_ROOT}/{packageID}/{name}/`). Five builtins: `create`,
|
||||||
|
`path`, `list`, `delete`, `usage`. Permission: `workspace.manage`.
|
||||||
|
Quota enforcement via `WORKSPACE_QUOTA_MB`. Path traversal and symlink
|
||||||
|
escape protection.
|
||||||
|
|
||||||
New: `sandbox/workspace_module.go`. Modified: runner wiring, permission
|
New: `sandbox/workspace_module.go`. Modified: runner wiring, permission
|
||||||
constants.
|
constants, config (`WORKSPACE_ROOT`, `WORKSPACE_QUOTA_MB`), main.go
|
||||||
|
startup. 16 new tests.
|
||||||
|
|
||||||
**v0.8.2 — Capability Negotiation**
|
**v0.8.2 — Capability Negotiation**
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,12 @@ type Config struct {
|
|||||||
S3Prefix string // optional key prefix within bucket (e.g. "armature/")
|
S3Prefix string // optional key prefix within bucket (e.g. "armature/")
|
||||||
S3ForcePathStyle bool // use path-style URLs (required for MinIO, most self-hosted)
|
S3ForcePathStyle bool // use path-style URLs (required for MinIO, most self-hosted)
|
||||||
|
|
||||||
|
// Extension workspaces
|
||||||
|
// WORKSPACE_ROOT: mount point for extension-managed directories (default /data/workspaces).
|
||||||
|
// WORKSPACE_QUOTA_MB: per-extension quota in MB (default 0 = unlimited).
|
||||||
|
WorkspaceRoot string
|
||||||
|
WorkspaceQuotaMB int
|
||||||
|
|
||||||
// Structured logging
|
// Structured logging
|
||||||
// LOG_FORMAT: "text" (default, backward-compatible) or "json" (structured).
|
// LOG_FORMAT: "text" (default, backward-compatible) or "json" (structured).
|
||||||
// LOG_LEVEL: "debug", "info" (default), "warn", "error".
|
// LOG_LEVEL: "debug", "info" (default), "warn", "error".
|
||||||
@@ -143,6 +149,9 @@ func Load() *Config {
|
|||||||
S3Prefix: getEnv("S3_PREFIX", ""),
|
S3Prefix: getEnv("S3_PREFIX", ""),
|
||||||
S3ForcePathStyle: getEnv("S3_FORCE_PATH_STYLE", "true") == "true",
|
S3ForcePathStyle: getEnv("S3_FORCE_PATH_STYLE", "true") == "true",
|
||||||
|
|
||||||
|
WorkspaceRoot: getEnv("WORKSPACE_ROOT", "/data/workspaces"),
|
||||||
|
WorkspaceQuotaMB: getEnvInt("WORKSPACE_QUOTA_MB", 0),
|
||||||
|
|
||||||
SkipBundledPackages: getEnvBool("SKIP_BUNDLED_PACKAGES", false),
|
SkipBundledPackages: getEnvBool("SKIP_BUNDLED_PACKAGES", false),
|
||||||
BundledPackagesDir: getEnv("BUNDLED_PACKAGES_DIR", "/app/bundled-packages"),
|
BundledPackagesDir: getEnv("BUNDLED_PACKAGES_DIR", "/app/bundled-packages"),
|
||||||
BundledPackages: getEnv("BUNDLED_PACKAGES", ""),
|
BundledPackages: getEnv("BUNDLED_PACKAGES", ""),
|
||||||
|
|||||||
@@ -176,6 +176,14 @@ func main() {
|
|||||||
starlarkRunner.SetAllowPrivateIPs(true)
|
starlarkRunner.SetAllowPrivateIPs(true)
|
||||||
log.Printf(" ⚠️ Extension SSRF protection relaxed: private IPs allowed")
|
log.Printf(" ⚠️ Extension SSRF protection relaxed: private IPs allowed")
|
||||||
}
|
}
|
||||||
|
if cfg.WorkspaceRoot != "" {
|
||||||
|
if err := os.MkdirAll(cfg.WorkspaceRoot, 0755); err == nil {
|
||||||
|
starlarkRunner.SetWorkspaceRoot(cfg.WorkspaceRoot, cfg.WorkspaceQuotaMB)
|
||||||
|
log.Printf(" workspace root: %s", cfg.WorkspaceRoot)
|
||||||
|
} else {
|
||||||
|
log.Printf(" workspace root %s not writable, workspace module disabled", cfg.WorkspaceRoot)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Bundled Packages ───────────────
|
// ── Bundled Packages ───────────────
|
||||||
// Auto-install bundled .pkg archives on first run.
|
// Auto-install bundled .pkg archives on first run.
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ const (
|
|||||||
ExtPermBatchExec = "batch.exec"
|
ExtPermBatchExec = "batch.exec"
|
||||||
ExtPermFilesRead = "files.read"
|
ExtPermFilesRead = "files.read"
|
||||||
ExtPermFilesWrite = "files.write"
|
ExtPermFilesWrite = "files.write"
|
||||||
|
ExtPermWorkspaceManage = "workspace.manage"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ValidExtensionPermissions is the set of recognized permission keys.
|
// ValidExtensionPermissions is the set of recognized permission keys.
|
||||||
@@ -56,6 +57,7 @@ var ValidExtensionPermissions = map[string]bool{
|
|||||||
ExtPermBatchExec: true,
|
ExtPermBatchExec: true,
|
||||||
ExtPermFilesRead: true,
|
ExtPermFilesRead: true,
|
||||||
ExtPermFilesWrite: true,
|
ExtPermFilesWrite: true,
|
||||||
|
ExtPermWorkspaceManage: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Extension Permission Model ───────────────
|
// ── Extension Permission Model ───────────────
|
||||||
|
|||||||
@@ -79,6 +79,8 @@ type Runner struct {
|
|||||||
allowPrivateIPs bool
|
allowPrivateIPs bool
|
||||||
bus *events.Bus
|
bus *events.Bus
|
||||||
objectStore storage.ObjectStore // nil = files module unavailable
|
objectStore storage.ObjectStore // nil = files module unavailable
|
||||||
|
workspaceRoot string // empty = workspace module unavailable
|
||||||
|
workspaceQuota int // MB, 0 = unlimited
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewRunner creates a runner with the given sandbox and dependencies.
|
// NewRunner creates a runner with the given sandbox and dependencies.
|
||||||
@@ -123,6 +125,13 @@ func (r *Runner) SetObjectStore(s storage.ObjectStore) {
|
|||||||
r.objectStore = s
|
r.objectStore = s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetWorkspaceRoot sets the base directory for extension workspaces.
|
||||||
|
// quotaMB is the per-extension quota in MB (0 = unlimited).
|
||||||
|
func (r *Runner) SetWorkspaceRoot(root string, quotaMB int) {
|
||||||
|
r.workspaceRoot = root
|
||||||
|
r.workspaceQuota = quotaMB
|
||||||
|
}
|
||||||
|
|
||||||
// SetAllowPrivateIPs disables the SSRF check that blocks connections to
|
// SetAllowPrivateIPs disables the SSRF check that blocks connections to
|
||||||
// private/loopback IPs. For self-hosted environments where extensions
|
// private/loopback IPs. For self-hosted environments where extensions
|
||||||
// reach internal services. Controlled by EXT_ALLOW_PRIVATE_IPS env var.
|
// reach internal services. Controlled by EXT_ALLOW_PRIVATE_IPS env var.
|
||||||
@@ -392,6 +401,15 @@ func (r *Runner) buildModulesWithLibCtx(ctx context.Context, packageID string, m
|
|||||||
modules["realtime"] = BuildRealtimeModule(ctx, r.bus, packageID)
|
modules["realtime"] = BuildRealtimeModule(ctx, r.bus, packageID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case models.ExtPermWorkspaceManage:
|
||||||
|
if r.workspaceRoot != "" {
|
||||||
|
modules["workspace"] = BuildWorkspaceModule(ctx, WorkspaceModuleConfig{
|
||||||
|
PackageID: packageID,
|
||||||
|
WorkspaceRoot: r.workspaceRoot,
|
||||||
|
QuotaMB: r.workspaceQuota,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
case models.ExtPermBatchExec:
|
case models.ExtPermBatchExec:
|
||||||
hasBatchExec = true
|
hasBatchExec = true
|
||||||
}
|
}
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
344
server/sandbox/workspace_module_test.go
Normal file
344
server/sandbox/workspace_module_test.go
Normal file
@@ -0,0 +1,344 @@
|
|||||||
|
package sandbox
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"go.starlark.net/starlark"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Test helpers ──────────────────────────────
|
||||||
|
|
||||||
|
func execWithWorkspace(t *testing.T, script string) (*Result, string, error) {
|
||||||
|
t.Helper()
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
ctx := context.Background()
|
||||||
|
mod := BuildWorkspaceModule(ctx, WorkspaceModuleConfig{
|
||||||
|
PackageID: "test-pkg",
|
||||||
|
WorkspaceRoot: tmpDir,
|
||||||
|
})
|
||||||
|
sb := New(DefaultConfig())
|
||||||
|
result, err := sb.Exec(ctx, "test.star", script, map[string]starlark.Value{
|
||||||
|
"workspace": mod,
|
||||||
|
})
|
||||||
|
return result, tmpDir, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func execWithWorkspaceQuota(t *testing.T, quotaMB int, script string) (*Result, string, error) {
|
||||||
|
t.Helper()
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
ctx := context.Background()
|
||||||
|
mod := BuildWorkspaceModule(ctx, WorkspaceModuleConfig{
|
||||||
|
PackageID: "test-pkg",
|
||||||
|
WorkspaceRoot: tmpDir,
|
||||||
|
QuotaMB: quotaMB,
|
||||||
|
})
|
||||||
|
sb := New(DefaultConfig())
|
||||||
|
result, err := sb.Exec(ctx, "test.star", script, map[string]starlark.Value{
|
||||||
|
"workspace": mod,
|
||||||
|
})
|
||||||
|
return result, tmpDir, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Create ────────────────────────────────────
|
||||||
|
|
||||||
|
func TestWorkspaceCreate_ReturnsPath(t *testing.T) {
|
||||||
|
result, tmpDir, err := execWithWorkspace(t, `
|
||||||
|
path = workspace.create("myrepo")
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("exec error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := string(result.Globals["path"].(starlark.String))
|
||||||
|
expected := filepath.Join(tmpDir, "test-pkg", "myrepo")
|
||||||
|
if got != expected {
|
||||||
|
t.Errorf("path = %q, want %q", got, expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWorkspaceCreate_Idempotent(t *testing.T) {
|
||||||
|
result, _, err := execWithWorkspace(t, `
|
||||||
|
path1 = workspace.create("myrepo")
|
||||||
|
path2 = workspace.create("myrepo")
|
||||||
|
same = (path1 == path2)
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("exec error: %v", err)
|
||||||
|
}
|
||||||
|
if result.Globals["same"] != starlark.True {
|
||||||
|
t.Error("expected same path on second create")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWorkspaceCreate_DirectoryExists(t *testing.T) {
|
||||||
|
result, _, err := execWithWorkspace(t, `
|
||||||
|
path = workspace.create("build_cache")
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("exec error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dir := string(result.Globals["path"].(starlark.String))
|
||||||
|
info, statErr := os.Stat(dir)
|
||||||
|
if statErr != nil {
|
||||||
|
t.Fatalf("directory does not exist: %v", statErr)
|
||||||
|
}
|
||||||
|
if !info.IsDir() {
|
||||||
|
t.Error("expected a directory")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Path ──────────────────────────────────────
|
||||||
|
|
||||||
|
func TestWorkspacePath_Exists(t *testing.T) {
|
||||||
|
result, tmpDir, err := execWithWorkspace(t, `
|
||||||
|
workspace.create("myrepo")
|
||||||
|
path = workspace.path("myrepo")
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("exec error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := string(result.Globals["path"].(starlark.String))
|
||||||
|
expected := filepath.Join(tmpDir, "test-pkg", "myrepo")
|
||||||
|
if got != expected {
|
||||||
|
t.Errorf("path = %q, want %q", got, expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWorkspacePath_NotFound(t *testing.T) {
|
||||||
|
result, _, err := execWithWorkspace(t, `
|
||||||
|
path = workspace.path("nonexistent")
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("exec error: %v", err)
|
||||||
|
}
|
||||||
|
if result.Globals["path"] != starlark.None {
|
||||||
|
t.Errorf("expected None, got %v", result.Globals["path"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── List ──────────────────────────────────────
|
||||||
|
|
||||||
|
func TestWorkspaceList_Empty(t *testing.T) {
|
||||||
|
result, _, err := execWithWorkspace(t, `
|
||||||
|
names = workspace.list()
|
||||||
|
count = len(names)
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("exec error: %v", err)
|
||||||
|
}
|
||||||
|
count, _ := starlark.AsInt32(result.Globals["count"])
|
||||||
|
if count != 0 {
|
||||||
|
t.Errorf("count = %d, want 0", count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWorkspaceList_Multiple(t *testing.T) {
|
||||||
|
result, _, err := execWithWorkspace(t, `
|
||||||
|
workspace.create("alpha")
|
||||||
|
workspace.create("beta")
|
||||||
|
workspace.create("gamma")
|
||||||
|
names = workspace.list()
|
||||||
|
count = len(names)
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("exec error: %v", err)
|
||||||
|
}
|
||||||
|
count, _ := starlark.AsInt32(result.Globals["count"])
|
||||||
|
if count != 3 {
|
||||||
|
t.Errorf("count = %d, want 3", count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Delete ────────────────────────────────────
|
||||||
|
|
||||||
|
func TestWorkspaceDelete_Removes(t *testing.T) {
|
||||||
|
result, _, err := execWithWorkspace(t, `
|
||||||
|
workspace.create("temp")
|
||||||
|
workspace.delete("temp")
|
||||||
|
path = workspace.path("temp")
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("exec error: %v", err)
|
||||||
|
}
|
||||||
|
if result.Globals["path"] != starlark.None {
|
||||||
|
t.Error("expected None after delete")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWorkspaceDelete_Idempotent(t *testing.T) {
|
||||||
|
_, _, err := execWithWorkspace(t, `
|
||||||
|
workspace.delete("nonexistent")
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("exec error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Usage ─────────────────────────────────────
|
||||||
|
|
||||||
|
func TestWorkspaceUsage_EmptyDir(t *testing.T) {
|
||||||
|
result, _, err := execWithWorkspace(t, `
|
||||||
|
workspace.create("empty")
|
||||||
|
bytes = workspace.usage("empty")
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("exec error: %v", err)
|
||||||
|
}
|
||||||
|
usage, _ := starlark.AsInt32(result.Globals["bytes"])
|
||||||
|
if usage != 0 {
|
||||||
|
t.Errorf("usage = %d, want 0", usage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWorkspaceUsage_WithFiles(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Pre-create workspace directory with a file.
|
||||||
|
wsDir := filepath.Join(tmpDir, "test-pkg", "data")
|
||||||
|
if err := os.MkdirAll(wsDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Write a 1024-byte file.
|
||||||
|
if err := os.WriteFile(filepath.Join(wsDir, "file.bin"), make([]byte, 1024), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mod := BuildWorkspaceModule(ctx, WorkspaceModuleConfig{
|
||||||
|
PackageID: "test-pkg",
|
||||||
|
WorkspaceRoot: tmpDir,
|
||||||
|
})
|
||||||
|
sb := New(DefaultConfig())
|
||||||
|
result, err := sb.Exec(ctx, "test.star", `bytes = workspace.usage("data")`, map[string]starlark.Value{
|
||||||
|
"workspace": mod,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("exec error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
usage, _ := starlark.AsInt32(result.Globals["bytes"])
|
||||||
|
if usage != 1024 {
|
||||||
|
t.Errorf("usage = %d, want 1024", usage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWorkspaceUsage_NonExistent(t *testing.T) {
|
||||||
|
_, _, err := execWithWorkspace(t, `
|
||||||
|
bytes = workspace.usage("nope")
|
||||||
|
`)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for nonexistent workspace")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "does not exist") {
|
||||||
|
t.Errorf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Name Validation ──────────────────────────
|
||||||
|
|
||||||
|
func TestWorkspaceNameValidation_BadNames(t *testing.T) {
|
||||||
|
badNames := []struct {
|
||||||
|
name string
|
||||||
|
desc string
|
||||||
|
}{
|
||||||
|
{"../escape", "traversal"},
|
||||||
|
{"UPPER", "uppercase"},
|
||||||
|
{"", "empty"},
|
||||||
|
{"has space", "space"},
|
||||||
|
{"has/slash", "slash"},
|
||||||
|
{".dotstart", "dot prefix"},
|
||||||
|
{"0numstart", "digit prefix"},
|
||||||
|
{strings.Repeat("a", 64), "too long"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range badNames {
|
||||||
|
t.Run(tc.desc, func(t *testing.T) {
|
||||||
|
script := fmt.Sprintf(`workspace.path(%q)`, tc.name)
|
||||||
|
_, _, err := execWithWorkspace(t, script)
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("expected error for name %q", tc.name)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWorkspaceNameValidation_GoodNames(t *testing.T) {
|
||||||
|
goodNames := []string{"myrepo", "build_cache", "a", "a0_b1_c2"}
|
||||||
|
|
||||||
|
for _, name := range goodNames {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
script := fmt.Sprintf(`path = workspace.path(%q)`, name)
|
||||||
|
_, _, err := execWithWorkspace(t, script)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("unexpected error for name %q: %v", name, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Quota ─────────────────────────────────────
|
||||||
|
|
||||||
|
func TestWorkspaceQuota_Enforced(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Pre-fill the package directory with >1 MB of data.
|
||||||
|
pkgDir := filepath.Join(tmpDir, "test-pkg", "existing")
|
||||||
|
if err := os.MkdirAll(pkgDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
bigFile := make([]byte, 1100*1024) // ~1.07 MB
|
||||||
|
if err := os.WriteFile(filepath.Join(pkgDir, "big.bin"), bigFile, 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mod := BuildWorkspaceModule(ctx, WorkspaceModuleConfig{
|
||||||
|
PackageID: "test-pkg",
|
||||||
|
WorkspaceRoot: tmpDir,
|
||||||
|
QuotaMB: 1,
|
||||||
|
})
|
||||||
|
sb := New(DefaultConfig())
|
||||||
|
_, err := sb.Exec(ctx, "test.star", `workspace.create("newone")`, map[string]starlark.Value{
|
||||||
|
"workspace": mod,
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected quota exceeded error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "quota exceeded") {
|
||||||
|
t.Errorf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWorkspaceQuota_Unlimited(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Pre-fill with data — should not matter with quota=0.
|
||||||
|
pkgDir := filepath.Join(tmpDir, "test-pkg", "existing")
|
||||||
|
if err := os.MkdirAll(pkgDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(pkgDir, "big.bin"), make([]byte, 2*1024*1024), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mod := BuildWorkspaceModule(ctx, WorkspaceModuleConfig{
|
||||||
|
PackageID: "test-pkg",
|
||||||
|
WorkspaceRoot: tmpDir,
|
||||||
|
QuotaMB: 0, // unlimited
|
||||||
|
})
|
||||||
|
sb := New(DefaultConfig())
|
||||||
|
_, err := sb.Exec(ctx, "test.star", `workspace.create("newone")`, map[string]starlark.Value{
|
||||||
|
"workspace": mod,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected success with unlimited quota: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user