feat: scaffold codespace backend
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
package fs
|
||||
|
||||
import "time"
|
||||
|
||||
// FileInfo describes a file or directory within a workspace.
|
||||
// Path is always workspace-relative; host absolute paths are never exposed.
|
||||
type FileInfo struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
IsDir bool `json:"isDir"`
|
||||
Size int64 `json:"size"`
|
||||
ModTime time.Time `json:"modTime"`
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package fs
|
||||
|
||||
// FileSystem abstracts file operations within a single workspace root.
|
||||
// All path arguments are workspace-relative.
|
||||
type FileSystem interface {
|
||||
List(path string) ([]FileInfo, error)
|
||||
Read(path string) ([]byte, error)
|
||||
Write(path string, data []byte) error
|
||||
Mkdir(path string) error
|
||||
Remove(path string) error
|
||||
Rename(oldPath, newPath string) error
|
||||
Stat(path string) (*FileInfo, error)
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package fs
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"codespace/internal/util"
|
||||
)
|
||||
|
||||
// LocalFS implements FileSystem bound to a single workspace root directory.
|
||||
type LocalFS struct {
|
||||
root string
|
||||
}
|
||||
|
||||
// NewLocal creates a LocalFS whose root is absolute and cleaned if possible.
|
||||
func NewLocal(root string) *LocalFS {
|
||||
abs, err := filepath.Abs(root)
|
||||
if err != nil {
|
||||
abs = filepath.Clean(root)
|
||||
}
|
||||
return &LocalFS{root: abs}
|
||||
}
|
||||
|
||||
// resolve validates path, cleans it, and returns the absolute path on disk
|
||||
// plus the workspace-relative form. It rejects paths that escape the root.
|
||||
func (l *LocalFS) resolve(path string) (abs string, rel string, err error) {
|
||||
rel, err = util.CleanRelative(path)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
abs = filepath.Join(l.root, rel)
|
||||
// Final containment check: ensure abs is within root.
|
||||
if abs != l.root && !strings.HasPrefix(abs, l.root+string(filepath.Separator)) {
|
||||
return "", "", util.New(util.CodeBadRequest, "path escapes workspace root")
|
||||
}
|
||||
return abs, rel, nil
|
||||
}
|
||||
|
||||
// List returns sorted directory entries for path.
|
||||
func (l *LocalFS) List(path string) ([]FileInfo, error) {
|
||||
abs, rel, err := l.resolve(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries, err := os.ReadDir(abs)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, util.New(util.CodeNotFound, "path not found")
|
||||
}
|
||||
return nil, util.Wrap(util.CodeInternal, "failed to list directory", err)
|
||||
}
|
||||
result := make([]FileInfo, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
info, err := e.Info()
|
||||
if err != nil {
|
||||
return nil, util.Wrap(util.CodeInternal, "failed to get file info", err)
|
||||
}
|
||||
result = append(result, FileInfo{
|
||||
Name: e.Name(),
|
||||
Path: filepath.Join(rel, e.Name()),
|
||||
IsDir: e.IsDir(),
|
||||
Size: info.Size(),
|
||||
ModTime: info.ModTime(),
|
||||
})
|
||||
}
|
||||
sort.Slice(result, func(i, j int) bool {
|
||||
return result[i].Name < result[j].Name
|
||||
})
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Read returns the contents of path.
|
||||
func (l *LocalFS) Read(path string) ([]byte, error) {
|
||||
abs, _, err := l.resolve(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, err := os.ReadFile(abs)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, util.New(util.CodeNotFound, "file not found")
|
||||
}
|
||||
return nil, util.Wrap(util.CodeInternal, "failed to read file", err)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// Write writes data to path, creating parent directories as needed.
|
||||
func (l *LocalFS) Write(path string, data []byte) error {
|
||||
abs, _, err := l.resolve(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
parent := filepath.Dir(abs)
|
||||
if err := os.MkdirAll(parent, 0o755); err != nil {
|
||||
return util.Wrap(util.CodeInternal, "failed to create parent directories", err)
|
||||
}
|
||||
if err := os.WriteFile(abs, data, 0o644); err != nil {
|
||||
return util.Wrap(util.CodeInternal, "failed to write file", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Mkdir creates a directory at path.
|
||||
func (l *LocalFS) Mkdir(path string) error {
|
||||
abs, _, err := l.resolve(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(abs, 0o755); err != nil {
|
||||
return util.Wrap(util.CodeInternal, "failed to create directory", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Remove removes path (file or directory recursively).
|
||||
func (l *LocalFS) Remove(path string) error {
|
||||
abs, _, err := l.resolve(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.RemoveAll(abs); err != nil {
|
||||
return util.Wrap(util.CodeInternal, "failed to remove path", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Rename moves oldPath to newPath, creating destination parent dirs.
|
||||
func (l *LocalFS) Rename(oldPath, newPath string) error {
|
||||
oldAbs, _, err := l.resolve(oldPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
newAbs, _, err := l.resolve(newPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
parent := filepath.Dir(newAbs)
|
||||
if err := os.MkdirAll(parent, 0o755); err != nil {
|
||||
return util.Wrap(util.CodeInternal, "failed to create parent directories", err)
|
||||
}
|
||||
if err := os.Rename(oldAbs, newAbs); err != nil {
|
||||
return util.Wrap(util.CodeInternal, "failed to rename", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stat returns metadata for path.
|
||||
func (l *LocalFS) Stat(path string) (*FileInfo, error) {
|
||||
abs, rel, err := l.resolve(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
info, err := os.Stat(abs)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, util.New(util.CodeNotFound, "path not found")
|
||||
}
|
||||
return nil, util.Wrap(util.CodeInternal, "failed to stat path", err)
|
||||
}
|
||||
return &FileInfo{
|
||||
Name: filepath.Base(rel),
|
||||
Path: rel,
|
||||
IsDir: info.IsDir(),
|
||||
Size: info.Size(),
|
||||
ModTime: info.ModTime(),
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package fs
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLocalFSWriteReadListStat(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
fsys := NewLocal(root)
|
||||
|
||||
content := []byte("package main\n")
|
||||
if err := fsys.Write("dir/main.go", content); err != nil {
|
||||
t.Fatalf("Write: %v", err)
|
||||
}
|
||||
|
||||
got, err := fsys.Read("dir/main.go")
|
||||
if err != nil {
|
||||
t.Fatalf("Read: %v", err)
|
||||
}
|
||||
if string(got) != string(content) {
|
||||
t.Errorf("Read = %q, want %q", got, content)
|
||||
}
|
||||
|
||||
entries, err := fsys.List("dir")
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
if len(entries) != 1 {
|
||||
t.Fatalf("List returned %d entries, want 1", len(entries))
|
||||
}
|
||||
if entries[0].Name != "main.go" {
|
||||
t.Errorf("entry name = %q, want main.go", entries[0].Name)
|
||||
}
|
||||
if entries[0].Path != "dir/main.go" {
|
||||
t.Errorf("entry path = %q, want dir/main.go", entries[0].Path)
|
||||
}
|
||||
if entries[0].IsDir {
|
||||
t.Error("expected IsDir=false")
|
||||
}
|
||||
|
||||
info, err := fsys.Stat("dir/main.go")
|
||||
if err != nil {
|
||||
t.Fatalf("Stat: %v", err)
|
||||
}
|
||||
if info.Size != int64(len(content)) {
|
||||
t.Errorf("Stat size = %d, want %d", info.Size, len(content))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalFSRejectsPathEscape(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
fsys := NewLocal(root)
|
||||
|
||||
// Create an outside file to verify writes don't reach it.
|
||||
outside := filepath.Join(root, "..", "outside_escape_test")
|
||||
defer os.Remove(outside)
|
||||
|
||||
ops := []struct {
|
||||
name string
|
||||
fn func() error
|
||||
}{
|
||||
{"Read", func() error { _, err := fsys.Read("../outside_escape_test"); return err }},
|
||||
{"Write", func() error { return fsys.Write("../outside_escape_test", []byte("x")) }},
|
||||
{"Mkdir", func() error { return fsys.Mkdir("../outside_escape_dir") }},
|
||||
{"Remove", func() error { return fsys.Remove("../outside_escape_test") }},
|
||||
{"Rename", func() error { return fsys.Rename("file", "../outside_escape_test") }},
|
||||
}
|
||||
|
||||
for _, op := range ops {
|
||||
if err := op.fn(); err == nil {
|
||||
t.Errorf("%s with ../ path should fail", op.name)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := os.Stat(outside); err == nil {
|
||||
t.Error("outside file should not have been created")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalFSRenameAndRemove(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
fsys := NewLocal(root)
|
||||
|
||||
if err := fsys.Write("old.txt", []byte("hello")); err != nil {
|
||||
t.Fatalf("Write: %v", err)
|
||||
}
|
||||
|
||||
if err := fsys.Rename("old.txt", "new.txt"); err != nil {
|
||||
t.Fatalf("Rename: %v", err)
|
||||
}
|
||||
|
||||
if _, err := fsys.Stat("old.txt"); err == nil {
|
||||
t.Error("old.txt should not exist after rename")
|
||||
}
|
||||
|
||||
data, err := fsys.Read("new.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("Read new.txt: %v", err)
|
||||
}
|
||||
if string(data) != "hello" {
|
||||
t.Errorf("Read new.txt = %q, want hello", data)
|
||||
}
|
||||
|
||||
if err := fsys.Remove("new.txt"); err != nil {
|
||||
t.Fatalf("Remove: %v", err)
|
||||
}
|
||||
if _, err := fsys.Stat("new.txt"); err == nil {
|
||||
t.Error("new.txt should not exist after remove")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalFSListSorted(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
fsys := NewLocal(root)
|
||||
|
||||
for _, name := range []string{"c.txt", "a.txt", "b.txt"} {
|
||||
if err := fsys.Write(name, []byte("x")); err != nil {
|
||||
t.Fatalf("Write %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
entries, err := fsys.List(".")
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
|
||||
names := make([]string, len(entries))
|
||||
for i, e := range entries {
|
||||
names[i] = e.Name
|
||||
}
|
||||
if !sort.StringsAreSorted(names) {
|
||||
t.Errorf("List not sorted: %v", names)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user