599 lines
17 KiB
Go
599 lines
17 KiB
Go
// Package workspace provides filesystem operations for workspaces.
|
|
// It operates on the PVC filesystem and keeps the DB metadata index in sync
|
|
// via the WorkspaceStore.
|
|
package workspace
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"mime"
|
|
"net/http"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"git.gobha.me/xcaliber/chat-switchboard/models"
|
|
"git.gobha.me/xcaliber/chat-switchboard/store"
|
|
)
|
|
|
|
// ── Configuration ───────────────────────────
|
|
|
|
const (
|
|
// DefaultMaxBytes is the default workspace quota (500 MB).
|
|
DefaultMaxBytes int64 = 500 * 1024 * 1024
|
|
|
|
// MaxSingleFileBytes is the max size for a single file write (100 MB).
|
|
MaxSingleFileBytes int64 = 100 * 1024 * 1024
|
|
|
|
// filesSubdir is the subdirectory within a workspace root that holds user files.
|
|
// Keeps metadata (.workspace.json, future .git/) out of the user's namespace.
|
|
filesSubdir = "files"
|
|
|
|
// metadataFile is the workspace metadata file stored in the workspace root.
|
|
metadataFile = ".workspace.json"
|
|
)
|
|
|
|
// ── FS ──────────────────────────────────────
|
|
|
|
// FS provides filesystem operations for workspaces.
|
|
// All file paths are relative to the workspace's files/ subdirectory.
|
|
// The DB metadata index is updated on every mutating operation.
|
|
type FS struct {
|
|
basePath string // e.g. /data/storage/workspaces
|
|
store store.WorkspaceStore
|
|
}
|
|
|
|
// NewFS creates a workspace filesystem manager.
|
|
func NewFS(basePath string, ws store.WorkspaceStore) *FS {
|
|
return &FS{basePath: basePath, store: ws}
|
|
}
|
|
|
|
// Init ensures the base workspaces directory exists.
|
|
func (fs *FS) Init() error {
|
|
return os.MkdirAll(fs.basePath, 0750)
|
|
}
|
|
|
|
// ── Path Helpers ────────────────────────────
|
|
|
|
// filesDir returns the absolute path to a workspace's file tree root.
|
|
func (fs *FS) filesDir(w *models.Workspace) string {
|
|
return filepath.Join(fs.basePath, w.ID, filesSubdir)
|
|
}
|
|
|
|
// absPath resolves a relative workspace path to an absolute filesystem path.
|
|
// Returns an error if the path escapes the workspace root (traversal attack).
|
|
func (fs *FS) absPath(w *models.Workspace, relPath string) (string, error) {
|
|
orig := strings.TrimSpace(relPath)
|
|
relPath = cleanPath(relPath)
|
|
if relPath == "" {
|
|
if orig != "" && orig != "." && orig != "./" {
|
|
// Non-empty input was sanitized to empty — traversal attempt
|
|
return "", fmt.Errorf("workspace: path traversal detected: %s", orig)
|
|
}
|
|
return fs.filesDir(w), nil
|
|
}
|
|
|
|
root := fs.filesDir(w)
|
|
abs := filepath.Join(root, relPath)
|
|
|
|
// Resolve symlinks and ensure we're still under root
|
|
absResolved, err := filepath.Abs(abs)
|
|
if err != nil {
|
|
return "", fmt.Errorf("workspace: invalid path: %w", err)
|
|
}
|
|
rootResolved, err := filepath.Abs(root)
|
|
if err != nil {
|
|
return "", fmt.Errorf("workspace: invalid root: %w", err)
|
|
}
|
|
|
|
if !strings.HasPrefix(absResolved, rootResolved+string(filepath.Separator)) && absResolved != rootResolved {
|
|
return "", fmt.Errorf("workspace: path traversal detected: %s", relPath)
|
|
}
|
|
|
|
return abs, nil
|
|
}
|
|
|
|
// cleanPath normalizes a relative path: removes leading slashes, cleans ./ and ../
|
|
func cleanPath(p string) string {
|
|
p = strings.TrimSpace(p)
|
|
p = path.Clean(p)
|
|
p = strings.TrimPrefix(p, "/")
|
|
p = strings.TrimPrefix(p, "./")
|
|
|
|
// Reject paths that resolve outside root
|
|
if p == ".." || strings.HasPrefix(p, "../") || strings.Contains(p, "/../") {
|
|
return ""
|
|
}
|
|
if p == "." {
|
|
return ""
|
|
}
|
|
return p
|
|
}
|
|
|
|
// ── Workspace Lifecycle ─────────────────────
|
|
|
|
// CreateDir creates the workspace directory structure on disk.
|
|
// Call after the DB row is created.
|
|
func (fs *FS) CreateDir(w *models.Workspace) error {
|
|
filesPath := fs.filesDir(w)
|
|
return os.MkdirAll(filesPath, 0750)
|
|
}
|
|
|
|
// Destroy removes the entire workspace directory from disk.
|
|
// Call after marking the workspace as "deleting" in DB.
|
|
func (fs *FS) Destroy(ctx context.Context, w *models.Workspace) error {
|
|
wsDir := filepath.Join(fs.basePath, w.ID)
|
|
|
|
// Remove all file index entries first
|
|
if err := fs.store.DeleteAllFiles(ctx, w.ID); err != nil {
|
|
log.Printf("workspace: failed to delete file index for %s: %v", w.ID, err)
|
|
}
|
|
|
|
return os.RemoveAll(wsDir)
|
|
}
|
|
|
|
// ── File Operations ─────────────────────────
|
|
|
|
// ReadFile returns a reader for the file at relPath.
|
|
// Caller must close the returned ReadCloser.
|
|
func (fs *FS) ReadFile(ctx context.Context, w *models.Workspace, relPath string) (io.ReadCloser, int64, error) {
|
|
abs, err := fs.absPath(w, relPath)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
info, err := os.Stat(abs)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil, 0, fmt.Errorf("workspace: file not found: %s", relPath)
|
|
}
|
|
return nil, 0, err
|
|
}
|
|
if info.IsDir() {
|
|
return nil, 0, fmt.Errorf("workspace: cannot read directory: %s", relPath)
|
|
}
|
|
|
|
f, err := os.Open(abs)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
return f, info.Size(), nil
|
|
}
|
|
|
|
// WriteFile creates or overwrites a file at relPath.
|
|
// Creates parent directories as needed. Updates the DB metadata index.
|
|
func (fs *FS) WriteFile(ctx context.Context, w *models.Workspace, relPath string, r io.Reader, size int64) error {
|
|
relPath = cleanPath(relPath)
|
|
if relPath == "" {
|
|
return fmt.Errorf("workspace: empty file path")
|
|
}
|
|
|
|
abs, err := fs.absPath(w, relPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Reject symlinks at destination
|
|
if info, err := os.Lstat(abs); err == nil && info.Mode()&os.ModeSymlink != 0 {
|
|
return fmt.Errorf("workspace: refusing to overwrite symlink: %s", relPath)
|
|
}
|
|
|
|
// Ensure parent directory exists
|
|
dir := filepath.Dir(abs)
|
|
if err := os.MkdirAll(dir, 0750); err != nil {
|
|
return fmt.Errorf("workspace: mkdir %s: %w", filepath.Dir(relPath), err)
|
|
}
|
|
|
|
// Write to temp file + rename (atomic)
|
|
tmp, err := os.CreateTemp(dir, ".ws-*.tmp")
|
|
if err != nil {
|
|
return fmt.Errorf("workspace: create temp: %w", err)
|
|
}
|
|
tmpName := tmp.Name()
|
|
defer func() {
|
|
tmp.Close()
|
|
os.Remove(tmpName) // cleanup on error; no-op after successful rename
|
|
}()
|
|
|
|
// Write content and compute hash simultaneously
|
|
h := sha256.New()
|
|
tee := io.TeeReader(r, h)
|
|
n, err := io.Copy(tmp, tee)
|
|
if err != nil {
|
|
return fmt.Errorf("workspace: write %s: %w", relPath, err)
|
|
}
|
|
if err := tmp.Close(); err != nil {
|
|
return fmt.Errorf("workspace: close temp: %w", err)
|
|
}
|
|
|
|
// Atomic rename
|
|
if err := os.Rename(tmpName, abs); err != nil {
|
|
return fmt.Errorf("workspace: rename %s: %w", relPath, err)
|
|
}
|
|
|
|
// Update DB index
|
|
contentType := detectContentType(relPath, abs)
|
|
hash := hex.EncodeToString(h.Sum(nil))
|
|
|
|
return fs.store.UpsertFile(ctx, &models.WorkspaceFile{
|
|
WorkspaceID: w.ID,
|
|
Path: relPath,
|
|
IsDirectory: false,
|
|
ContentType: contentType,
|
|
SizeBytes: n,
|
|
SHA256: hash,
|
|
})
|
|
}
|
|
|
|
// DeleteFile removes a file or empty directory at relPath.
|
|
func (fs *FS) DeleteFile(ctx context.Context, w *models.Workspace, relPath string, recursive bool) error {
|
|
relPath = cleanPath(relPath)
|
|
if relPath == "" {
|
|
return fmt.Errorf("workspace: cannot delete workspace root")
|
|
}
|
|
|
|
abs, err := fs.absPath(w, relPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
info, err := os.Stat(abs)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
// Clean up DB index anyway
|
|
fs.store.DeleteFile(ctx, w.ID, relPath)
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
|
|
if info.IsDir() {
|
|
if recursive {
|
|
if err := os.RemoveAll(abs); err != nil {
|
|
return err
|
|
}
|
|
// Remove all files under this prefix from the index
|
|
prefix := relPath
|
|
if !strings.HasSuffix(prefix, "/") {
|
|
prefix += "/"
|
|
}
|
|
fs.store.DeleteFilesByPrefix(ctx, w.ID, prefix)
|
|
return fs.store.DeleteFile(ctx, w.ID, relPath)
|
|
}
|
|
// Non-recursive: only remove empty dirs
|
|
if err := os.Remove(abs); err != nil {
|
|
return fmt.Errorf("workspace: directory not empty (use recursive=true): %s", relPath)
|
|
}
|
|
} else {
|
|
if err := os.Remove(abs); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return fs.store.DeleteFile(ctx, w.ID, relPath)
|
|
}
|
|
|
|
// Mkdir creates a directory at relPath. Creates parents as needed.
|
|
func (fs *FS) Mkdir(ctx context.Context, w *models.Workspace, relPath string) error {
|
|
relPath = cleanPath(relPath)
|
|
if relPath == "" {
|
|
return nil // root always exists
|
|
}
|
|
|
|
abs, err := fs.absPath(w, relPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := os.MkdirAll(abs, 0750); err != nil {
|
|
return err
|
|
}
|
|
|
|
return fs.store.UpsertFile(ctx, &models.WorkspaceFile{
|
|
WorkspaceID: w.ID,
|
|
Path: relPath,
|
|
IsDirectory: true,
|
|
})
|
|
}
|
|
|
|
// MoveFile renames or moves a file within the workspace.
|
|
// Creates destination parent directories as needed.
|
|
func (fs *FS) MoveFile(ctx context.Context, w *models.Workspace, srcRel, dstRel string) error {
|
|
srcRel = cleanPath(srcRel)
|
|
dstRel = cleanPath(dstRel)
|
|
if srcRel == "" || dstRel == "" {
|
|
return fmt.Errorf("workspace: source and destination paths required")
|
|
}
|
|
if srcRel == dstRel {
|
|
return nil // no-op
|
|
}
|
|
|
|
srcAbs, err := fs.absPath(w, srcRel)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
dstAbs, err := fs.absPath(w, dstRel)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Ensure source exists
|
|
srcInfo, err := os.Stat(srcAbs)
|
|
if err != nil {
|
|
return fmt.Errorf("workspace: source not found: %s", srcRel)
|
|
}
|
|
|
|
// Ensure destination parent exists
|
|
if err := os.MkdirAll(filepath.Dir(dstAbs), 0750); err != nil {
|
|
return fmt.Errorf("workspace: mkdir for move: %w", err)
|
|
}
|
|
|
|
// Rename on disk
|
|
if err := os.Rename(srcAbs, dstAbs); err != nil {
|
|
return fmt.Errorf("workspace: rename %s → %s: %w", srcRel, dstRel, err)
|
|
}
|
|
|
|
// Update DB index: delete old, upsert new
|
|
if srcInfo.IsDir() {
|
|
// For directories, delete all files under old prefix and reconcile new prefix.
|
|
// Simple approach: delete old entries, let next reconcile pick up new ones.
|
|
if err := fs.store.DeleteFilesByPrefix(ctx, w.ID, srcRel+"/"); err != nil {
|
|
log.Printf("workspace move: cleanup old prefix %s: %v", srcRel, err)
|
|
}
|
|
fs.store.DeleteFile(ctx, w.ID, srcRel)
|
|
// Upsert new directory entry
|
|
fs.store.UpsertFile(ctx, &models.WorkspaceFile{
|
|
WorkspaceID: w.ID,
|
|
Path: dstRel,
|
|
IsDirectory: true,
|
|
})
|
|
} else {
|
|
// Read metadata from old entry if available
|
|
oldFile, _ := fs.store.GetFile(ctx, w.ID, srcRel)
|
|
fs.store.DeleteFile(ctx, w.ID, srcRel)
|
|
|
|
newFile := &models.WorkspaceFile{
|
|
WorkspaceID: w.ID,
|
|
Path: dstRel,
|
|
IsDirectory: false,
|
|
SizeBytes: srcInfo.Size(),
|
|
ContentType: detectContentType(dstRel, dstAbs),
|
|
}
|
|
if oldFile != nil {
|
|
newFile.SHA256 = oldFile.SHA256
|
|
}
|
|
fs.store.UpsertFile(ctx, newFile)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Stat returns file metadata without reading content.
|
|
func (fs *FS) Stat(ctx context.Context, w *models.Workspace, relPath string) (*models.WorkspaceFile, error) {
|
|
relPath = cleanPath(relPath)
|
|
|
|
// Try DB first (fast)
|
|
f, err := fs.store.GetFile(ctx, w.ID, relPath)
|
|
if err == nil {
|
|
return f, nil
|
|
}
|
|
|
|
// Fall back to filesystem
|
|
abs, err := fs.absPath(w, relPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
info, err := os.Stat(abs)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("workspace: file not found: %s", relPath)
|
|
}
|
|
|
|
return &models.WorkspaceFile{
|
|
WorkspaceID: w.ID,
|
|
Path: relPath,
|
|
IsDirectory: info.IsDir(),
|
|
SizeBytes: info.Size(),
|
|
ContentType: detectContentType(relPath, abs),
|
|
}, nil
|
|
}
|
|
|
|
// ListDir returns files at the given prefix.
|
|
func (fs *FS) ListDir(ctx context.Context, w *models.Workspace, prefix string, recursive bool) ([]models.WorkspaceFile, error) {
|
|
prefix = cleanPath(prefix)
|
|
if prefix != "" && !strings.HasSuffix(prefix, "/") {
|
|
prefix += "/"
|
|
}
|
|
return fs.store.ListFiles(ctx, w.ID, prefix, recursive)
|
|
}
|
|
|
|
// Tree returns all files in the workspace (recursive from root).
|
|
func (fs *FS) Tree(ctx context.Context, w *models.Workspace) ([]models.WorkspaceFile, error) {
|
|
return fs.store.ListFiles(ctx, w.ID, "", true)
|
|
}
|
|
|
|
// ── Reconcile ───────────────────────────────
|
|
|
|
// Reconcile walks the filesystem and syncs the DB index.
|
|
// Adds missing entries, removes stale entries, updates sizes/hashes.
|
|
func (fs *FS) Reconcile(ctx context.Context, w *models.Workspace) (added, removed, updated int, err error) {
|
|
root := fs.filesDir(w)
|
|
|
|
// Walk FS and collect all paths
|
|
fsPaths := make(map[string]os.FileInfo)
|
|
err = filepath.Walk(root, func(abs string, info os.FileInfo, walkErr error) error {
|
|
if walkErr != nil {
|
|
return walkErr
|
|
}
|
|
rel, err := filepath.Rel(root, abs)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if rel == "." {
|
|
return nil
|
|
}
|
|
// Normalize to forward slashes
|
|
rel = filepath.ToSlash(rel)
|
|
fsPaths[rel] = info
|
|
return nil
|
|
})
|
|
if err != nil && !os.IsNotExist(err) {
|
|
return 0, 0, 0, fmt.Errorf("workspace: reconcile walk: %w", err)
|
|
}
|
|
|
|
// Get all DB entries
|
|
dbFiles, err := fs.store.ListFiles(ctx, w.ID, "", true)
|
|
if err != nil {
|
|
return 0, 0, 0, fmt.Errorf("workspace: reconcile list: %w", err)
|
|
}
|
|
|
|
dbPaths := make(map[string]*models.WorkspaceFile, len(dbFiles))
|
|
for i := range dbFiles {
|
|
dbPaths[dbFiles[i].Path] = &dbFiles[i]
|
|
}
|
|
|
|
// Add missing FS entries to DB
|
|
for relPath, info := range fsPaths {
|
|
if _, exists := dbPaths[relPath]; !exists {
|
|
abs := filepath.Join(root, relPath)
|
|
f := &models.WorkspaceFile{
|
|
WorkspaceID: w.ID,
|
|
Path: relPath,
|
|
IsDirectory: info.IsDir(),
|
|
SizeBytes: info.Size(),
|
|
ContentType: detectContentType(relPath, abs),
|
|
}
|
|
if !info.IsDir() {
|
|
if hash, hashErr := hashFile(abs); hashErr == nil {
|
|
f.SHA256 = hash
|
|
}
|
|
}
|
|
if upsertErr := fs.store.UpsertFile(ctx, f); upsertErr == nil {
|
|
added++
|
|
}
|
|
}
|
|
}
|
|
|
|
// Remove DB entries not on FS
|
|
for dbPath := range dbPaths {
|
|
if _, exists := fsPaths[dbPath]; !exists {
|
|
if delErr := fs.store.DeleteFile(ctx, w.ID, dbPath); delErr == nil {
|
|
removed++
|
|
}
|
|
}
|
|
}
|
|
|
|
log.Printf("workspace: reconcile %s: +%d -%d ~%d", w.ID, added, removed, updated)
|
|
return added, removed, updated, nil
|
|
}
|
|
|
|
// ── Content Type Detection ──────────────────
|
|
|
|
// detectContentType determines the MIME type from extension first,
|
|
// then falls back to http.DetectContentType on the first 512 bytes.
|
|
func detectContentType(relPath, absPath string) string {
|
|
// Extension-based detection first (covers source code)
|
|
ext := strings.ToLower(filepath.Ext(relPath))
|
|
if ct := mimeByExtension(ext); ct != "" {
|
|
return ct
|
|
}
|
|
|
|
// Sniff the first 512 bytes
|
|
f, err := os.Open(absPath)
|
|
if err != nil {
|
|
return "application/octet-stream"
|
|
}
|
|
defer f.Close()
|
|
|
|
buf := make([]byte, 512)
|
|
n, _ := f.Read(buf)
|
|
if n == 0 {
|
|
return "application/octet-stream"
|
|
}
|
|
return http.DetectContentType(buf[:n])
|
|
}
|
|
|
|
// mimeByExtension returns MIME type for common extensions.
|
|
// mime.TypeByExtension misses many source code types.
|
|
func mimeByExtension(ext string) string {
|
|
// Source code extensions that mime.TypeByExtension doesn't know about
|
|
codeTypes := map[string]string{
|
|
".go": "text/x-go",
|
|
".rs": "text/x-rust",
|
|
".py": "text/x-python",
|
|
".rb": "text/x-ruby",
|
|
".java": "text/x-java",
|
|
".kt": "text/x-kotlin",
|
|
".swift": "text/x-swift",
|
|
".c": "text/x-c",
|
|
".cpp": "text/x-c++",
|
|
".h": "text/x-c",
|
|
".hpp": "text/x-c++",
|
|
".ts": "text/typescript",
|
|
".tsx": "text/typescript",
|
|
".jsx": "text/jsx",
|
|
".vue": "text/x-vue",
|
|
".svelte": "text/x-svelte",
|
|
".scala": "text/x-scala",
|
|
".pl": "text/x-perl",
|
|
".pm": "text/x-perl",
|
|
".lua": "text/x-lua",
|
|
".zig": "text/x-zig",
|
|
".nim": "text/x-nim",
|
|
".ex": "text/x-elixir",
|
|
".exs": "text/x-elixir",
|
|
".sh": "text/x-shellscript",
|
|
".bash": "text/x-shellscript",
|
|
".zsh": "text/x-shellscript",
|
|
".fish": "text/x-shellscript",
|
|
".sql": "text/x-sql",
|
|
".tf": "text/x-terraform",
|
|
".hcl": "text/x-hcl",
|
|
".proto": "text/x-protobuf",
|
|
".graphql": "text/x-graphql",
|
|
".toml": "application/toml",
|
|
".yaml": "text/yaml",
|
|
".yml": "text/yaml",
|
|
".dockerfile": "text/x-dockerfile",
|
|
".mod": "text/x-go-mod",
|
|
".sum": "text/x-go-sum",
|
|
".lock": "text/plain",
|
|
".gitignore": "text/plain",
|
|
".env": "text/plain",
|
|
".editorconfig": "text/plain",
|
|
}
|
|
|
|
if ct, ok := codeTypes[ext]; ok {
|
|
return ct
|
|
}
|
|
|
|
// Fall back to standard library
|
|
if ct := mime.TypeByExtension(ext); ct != "" {
|
|
return ct
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
// ── Hashing ─────────────────────────────────
|
|
|
|
// hashFile computes SHA256 of a file.
|
|
func hashFile(absPath string) (string, error) {
|
|
f, err := os.Open(absPath)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer f.Close()
|
|
|
|
h := sha256.New()
|
|
if _, err := io.Copy(h, f); err != nil {
|
|
return "", err
|
|
}
|
|
return hex.EncodeToString(h.Sum(nil)), nil
|
|
}
|