// 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 indexer *Indexer // optional — set via SetIndexer for v0.21.2 indexing } // NewFS creates a workspace filesystem manager. func NewFS(basePath string, ws store.WorkspaceStore) *FS { return &FS{basePath: basePath, store: ws} } // SetIndexer attaches the workspace indexer for background file indexing. // Must be called after NewIndexer is created (circular dependency break). func (fs *FS) SetIndexer(idx *Indexer) { fs.indexer = idx } // 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) } // Check existing sha256 for content-addressed skip (v0.21.2) var oldSHA256 string if existing, err := fs.store.GetFile(ctx, w.ID, relPath); err == nil { oldSHA256 = existing.SHA256 } // 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) newHash := hex.EncodeToString(h.Sum(nil)) f := &models.WorkspaceFile{ WorkspaceID: w.ID, Path: relPath, IsDirectory: false, ContentType: contentType, SizeBytes: n, SHA256: newHash, } if err := fs.store.UpsertFile(ctx, f); err != nil { return err } // Trigger async indexing if content changed (v0.21.2) if fs.indexer != nil && newHash != oldSHA256 { // Use workspace owner for embedding provider resolution var teamID *string if w.OwnerType == models.WorkspaceOwnerTeam { teamID = &w.OwnerID } fs.indexer.IndexFile(w, f, w.OwnerID, teamID) } return nil } // 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 }