From 435f972dedc97197a5c0b7dd87af77df71562150 Mon Sep 17 00:00:00 2001 From: Jeffrey Smith Date: Fri, 3 Apr 2026 00:38:40 +0000 Subject: [PATCH] Feat v0.8.1 workspace module (#68) Co-authored-by: Jeffrey Smith Co-committed-by: Jeffrey Smith --- CHANGELOG.md | 29 ++ ROADMAP.md | 14 +- VERSION | 2 +- server/config/config.go | 9 + server/main.go | 8 + server/models/models_extension_perm.go | 2 + server/sandbox/runner.go | 18 ++ server/sandbox/workspace_module.go | 261 ++++++++++++++++++ server/sandbox/workspace_module_test.go | 344 ++++++++++++++++++++++++ 9 files changed, 681 insertions(+), 6 deletions(-) create mode 100644 server/sandbox/workspace_module.go create mode 100644 server/sandbox/workspace_module_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index c6d9abe..e9fba1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,35 @@ 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 New `files` sandbox module. Bridges the existing ObjectStore (PVC/S3) into diff --git a/ROADMAP.md b/ROADMAP.md index 495c0f9..cc0ebe3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,6 +1,6 @@ # Armature — Roadmap -## Current: v0.8.0 — Files Module +## Current: v0.8.1 — Workspace Module Self-hosted extensible platform kernel. Auth, identity, packages, Starlark 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 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, -compilers, media tools). `workspace.create(name)` → scoped path. -Permission: `workspace.manage`. Quota enforcement via `WORKSPACE_QUOTA_MB`. +compilers, media tools). Extension-scoped path namespacing +(`{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 -constants. +constants, config (`WORKSPACE_ROOT`, `WORKSPACE_QUOTA_MB`), main.go +startup. 16 new tests. **v0.8.2 — Capability Negotiation** diff --git a/VERSION b/VERSION index a3df0a6..6f4eebd 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.8.0 +0.8.1 diff --git a/server/config/config.go b/server/config/config.go index 4fef293..34ad265 100644 --- a/server/config/config.go +++ b/server/config/config.go @@ -47,6 +47,12 @@ type Config struct { S3Prefix string // optional key prefix within bucket (e.g. "armature/") 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 // LOG_FORMAT: "text" (default, backward-compatible) or "json" (structured). // LOG_LEVEL: "debug", "info" (default), "warn", "error". @@ -143,6 +149,9 @@ func Load() *Config { S3Prefix: getEnv("S3_PREFIX", ""), 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), BundledPackagesDir: getEnv("BUNDLED_PACKAGES_DIR", "/app/bundled-packages"), BundledPackages: getEnv("BUNDLED_PACKAGES", ""), diff --git a/server/main.go b/server/main.go index b7d1702..1168983 100644 --- a/server/main.go +++ b/server/main.go @@ -176,6 +176,14 @@ func main() { starlarkRunner.SetAllowPrivateIPs(true) 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 ─────────────── // Auto-install bundled .pkg archives on first run. diff --git a/server/models/models_extension_perm.go b/server/models/models_extension_perm.go index a85e94d..2eb146e 100644 --- a/server/models/models_extension_perm.go +++ b/server/models/models_extension_perm.go @@ -39,6 +39,7 @@ const ( ExtPermBatchExec = "batch.exec" ExtPermFilesRead = "files.read" ExtPermFilesWrite = "files.write" + ExtPermWorkspaceManage = "workspace.manage" ) // ValidExtensionPermissions is the set of recognized permission keys. @@ -56,6 +57,7 @@ var ValidExtensionPermissions = map[string]bool{ ExtPermBatchExec: true, ExtPermFilesRead: true, ExtPermFilesWrite: true, + ExtPermWorkspaceManage: true, } // ── Extension Permission Model ─────────────── diff --git a/server/sandbox/runner.go b/server/sandbox/runner.go index 7a65dd0..50511b4 100644 --- a/server/sandbox/runner.go +++ b/server/sandbox/runner.go @@ -79,6 +79,8 @@ type Runner struct { allowPrivateIPs bool bus *events.Bus 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. @@ -123,6 +125,13 @@ func (r *Runner) SetObjectStore(s storage.ObjectStore) { 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 // private/loopback IPs. For self-hosted environments where extensions // 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) } + case models.ExtPermWorkspaceManage: + if r.workspaceRoot != "" { + modules["workspace"] = BuildWorkspaceModule(ctx, WorkspaceModuleConfig{ + PackageID: packageID, + WorkspaceRoot: r.workspaceRoot, + QuotaMB: r.workspaceQuota, + }) + } + case models.ExtPermBatchExec: hasBatchExec = true } diff --git a/server/sandbox/workspace_module.go b/server/sandbox/workspace_module.go new file mode 100644 index 0000000..01e4b06 --- /dev/null +++ b/server/sandbox/workspace_module.go @@ -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 +} diff --git a/server/sandbox/workspace_module_test.go b/server/sandbox/workspace_module_test.go new file mode 100644 index 0000000..d906de6 --- /dev/null +++ b/server/sandbox/workspace_module_test.go @@ -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) + } +}