feat: scaffold codespace backend
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
package workspace
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"codespace/internal/util"
|
||||
)
|
||||
|
||||
// Manager manages workspace lifecycle.
|
||||
type Manager interface {
|
||||
Create(id string) (*Workspace, error)
|
||||
Get(id string) (*Workspace, error)
|
||||
Delete(id string) error
|
||||
}
|
||||
|
||||
// LocalManager implements Manager using the local filesystem.
|
||||
type LocalManager struct {
|
||||
root string
|
||||
}
|
||||
|
||||
// NewLocalManager creates a LocalManager rooted at root. The root is made
|
||||
// absolute and cleaned if possible.
|
||||
func NewLocalManager(root string) *LocalManager {
|
||||
abs, err := filepath.Abs(root)
|
||||
if err != nil {
|
||||
abs = filepath.Clean(root)
|
||||
}
|
||||
return &LocalManager{root: abs}
|
||||
}
|
||||
|
||||
// Create creates a workspace directory and returns the workspace.
|
||||
func (m *LocalManager) Create(id string) (*Workspace, error) {
|
||||
if !ValidID(id) {
|
||||
return nil, util.New(util.CodeBadRequest, "invalid workspace id")
|
||||
}
|
||||
root := RootFor(m.root, id)
|
||||
if err := os.MkdirAll(root, 0o755); err != nil {
|
||||
return nil, util.Wrap(util.CodeInternal, "failed to create workspace", err)
|
||||
}
|
||||
return &Workspace{ID: id, Root: root}, nil
|
||||
}
|
||||
|
||||
// Get returns the workspace if its directory exists.
|
||||
func (m *LocalManager) Get(id string) (*Workspace, error) {
|
||||
if !ValidID(id) {
|
||||
return nil, util.New(util.CodeBadRequest, "invalid workspace id")
|
||||
}
|
||||
root := RootFor(m.root, id)
|
||||
info, err := os.Stat(root)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, util.New(util.CodeNotFound, "workspace not found")
|
||||
}
|
||||
return nil, util.Wrap(util.CodeInternal, "failed to stat workspace", err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return nil, util.New(util.CodeNotFound, "workspace not found")
|
||||
}
|
||||
return &Workspace{ID: id, Root: root}, nil
|
||||
}
|
||||
|
||||
// Delete removes the workspace directory recursively.
|
||||
func (m *LocalManager) Delete(id string) error {
|
||||
if !ValidID(id) {
|
||||
return util.New(util.CodeBadRequest, "invalid workspace id")
|
||||
}
|
||||
root := RootFor(m.root, id)
|
||||
if err := os.RemoveAll(root); err != nil {
|
||||
return util.Wrap(util.CodeInternal, "failed to delete workspace", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package workspace
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
|
||||
"codespace/internal/util"
|
||||
)
|
||||
|
||||
var idPattern = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
|
||||
|
||||
// ValidID reports whether id is a safe workspace identifier: only letters,
|
||||
// digits, '-', '_', '.' and non-empty. The bare values "." and ".." are
|
||||
// rejected because they would resolve to the parent or current directory.
|
||||
func ValidID(id string) bool {
|
||||
if id == "" || id == "." || id == ".." {
|
||||
return false
|
||||
}
|
||||
return idPattern.MatchString(id)
|
||||
}
|
||||
|
||||
// RootFor returns the absolute workspace root path for the given base root and
|
||||
// workspace id. The result is filepath-cleaned.
|
||||
func RootFor(baseRoot, id string) string {
|
||||
return util.JoinClean(baseRoot, id)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package workspace
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidIDAcceptsSafeSegments(t *testing.T) {
|
||||
valid := []string{"user1", "project-A", "project_A", "project.1"}
|
||||
for _, id := range valid {
|
||||
if !ValidID(id) {
|
||||
t.Errorf("expected ValidID(%q) to be true", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidIDRejectsUnsafeSegments(t *testing.T) {
|
||||
invalid := []string{"", ".", "..", "/tmp/x", "../x", "a/b", "a b", "中文", "*"}
|
||||
for _, id := range invalid {
|
||||
if ValidID(id) {
|
||||
t.Errorf("expected ValidID(%q) to be false", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootForUsesConfiguredRoot(t *testing.T) {
|
||||
got := RootFor("/tmp/workspaces", "user1")
|
||||
want := "/tmp/workspaces/user1"
|
||||
if got != want {
|
||||
t.Errorf("RootFor = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package workspace
|
||||
|
||||
// Workspace represents a single workspace backed by a local directory.
|
||||
type Workspace struct {
|
||||
ID string `json:"id"`
|
||||
Root string `json:"-"`
|
||||
}
|
||||
Reference in New Issue
Block a user