Changeset 0.21.1 (#86)

This commit is contained in:
2026-03-01 14:44:55 +00:00
parent 817062e5fc
commit 70aa78e486
18 changed files with 3778 additions and 29 deletions

497
server/workspace/archive.go Normal file
View File

@@ -0,0 +1,497 @@
package workspace
import (
"archive/tar"
"archive/zip"
"compress/gzip"
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"log"
"os"
"path/filepath"
"strings"
"git.gobha.me/xcaliber/chat-switchboard/models"
)
// ── Archive Configuration ───────────────────
const (
// MaxArchiveFiles is the max number of files to extract from an archive.
MaxArchiveFiles = 10000
// MaxArchiveFileSize is the max size of a single file within an archive.
MaxArchiveFileSize int64 = 100 * 1024 * 1024 // 100 MB
)
// ── Extract ─────────────────────────────────
// ExtractArchive extracts a zip or tar.gz archive into the workspace.
// Returns the number of files extracted. Respects workspace quota.
// format must be "zip" or "tar.gz".
func (fs *FS) ExtractArchive(ctx context.Context, w *models.Workspace, archivePath, format string) (int, error) {
switch format {
case "zip":
return fs.extractZip(ctx, w, archivePath)
case "tar.gz", "tgz":
return fs.extractTarGz(ctx, w, archivePath)
default:
return 0, fmt.Errorf("workspace: unsupported archive format: %s", format)
}
}
func (fs *FS) extractZip(ctx context.Context, w *models.Workspace, archivePath string) (int, error) {
r, err := zip.OpenReader(archivePath)
if err != nil {
return 0, fmt.Errorf("workspace: open zip: %w", err)
}
defer r.Close()
if len(r.File) > MaxArchiveFiles {
return 0, fmt.Errorf("workspace: archive contains %d files (max %d)", len(r.File), MaxArchiveFiles)
}
// Detect common root prefix (e.g. "project-name/") and strip it
prefix := detectCommonPrefix(zipFileNames(r.File))
root := fs.filesDir(w)
count := 0
var totalBytes int64
for _, f := range r.File {
if err := ctx.Err(); err != nil {
return count, err
}
name := stripPrefix(f.Name, prefix)
if name == "" || name == "." {
continue
}
// Security: reject absolute paths and traversal
if isUnsafePath(name) {
log.Printf("workspace: skipping unsafe zip entry: %s", f.Name)
continue
}
destPath := filepath.Join(root, filepath.FromSlash(name))
if f.FileInfo().IsDir() {
if err := os.MkdirAll(destPath, 0750); err != nil {
return count, err
}
fs.store.UpsertFile(ctx, &models.WorkspaceFile{
WorkspaceID: w.ID,
Path: name,
IsDirectory: true,
})
continue
}
// Size check
if f.UncompressedSize64 > uint64(MaxArchiveFileSize) {
log.Printf("workspace: skipping oversized file: %s (%d bytes)", name, f.UncompressedSize64)
continue
}
// Quota check
quota := DefaultMaxBytes
if w.MaxBytes != nil {
quota = *w.MaxBytes
}
if totalBytes+int64(f.UncompressedSize64) > quota {
return count, fmt.Errorf("workspace: quota exceeded during extraction (%d bytes)", quota)
}
// Extract file
rc, err := f.Open()
if err != nil {
return count, fmt.Errorf("workspace: open zip entry %s: %w", name, err)
}
n, hash, err := extractToFile(destPath, rc, MaxArchiveFileSize)
rc.Close()
if err != nil {
return count, fmt.Errorf("workspace: extract %s: %w", name, err)
}
totalBytes += n
count++
contentType := detectContentType(name, destPath)
fs.store.UpsertFile(ctx, &models.WorkspaceFile{
WorkspaceID: w.ID,
Path: name,
IsDirectory: false,
ContentType: contentType,
SizeBytes: n,
SHA256: hash,
})
}
log.Printf("workspace: extracted %d files from zip (%d bytes)", count, totalBytes)
return count, nil
}
func (fs *FS) extractTarGz(ctx context.Context, w *models.Workspace, archivePath string) (int, error) {
f, err := os.Open(archivePath)
if err != nil {
return 0, fmt.Errorf("workspace: open tar.gz: %w", err)
}
defer f.Close()
gz, err := gzip.NewReader(f)
if err != nil {
return 0, fmt.Errorf("workspace: gzip reader: %w", err)
}
defer gz.Close()
tr := tar.NewReader(gz)
root := fs.filesDir(w)
count := 0
var totalBytes int64
// First pass: collect names to detect common prefix
// Since tar is streaming, we can't do two passes easily.
// We'll detect prefix from the first entry's directory.
var prefix string
prefixDetected := false
for {
if err := ctx.Err(); err != nil {
return count, err
}
header, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return count, fmt.Errorf("workspace: tar read: %w", err)
}
if count >= MaxArchiveFiles {
return count, fmt.Errorf("workspace: archive contains more than %d files", MaxArchiveFiles)
}
// Detect prefix from first entry
if !prefixDetected {
if idx := strings.IndexByte(header.Name, '/'); idx > 0 {
prefix = header.Name[:idx+1]
}
prefixDetected = true
}
name := stripPrefix(header.Name, prefix)
if name == "" || name == "." {
continue
}
if isUnsafePath(name) {
log.Printf("workspace: skipping unsafe tar entry: %s", header.Name)
continue
}
destPath := filepath.Join(root, filepath.FromSlash(name))
switch header.Typeflag {
case tar.TypeDir:
if err := os.MkdirAll(destPath, 0750); err != nil {
return count, err
}
fs.store.UpsertFile(ctx, &models.WorkspaceFile{
WorkspaceID: w.ID,
Path: name,
IsDirectory: true,
})
case tar.TypeReg:
if header.Size > MaxArchiveFileSize {
log.Printf("workspace: skipping oversized file: %s (%d bytes)", name, header.Size)
continue
}
quota := DefaultMaxBytes
if w.MaxBytes != nil {
quota = *w.MaxBytes
}
if totalBytes+header.Size > quota {
return count, fmt.Errorf("workspace: quota exceeded during extraction (%d bytes)", quota)
}
n, hash, extractErr := extractToFile(destPath, tr, MaxArchiveFileSize)
if extractErr != nil {
return count, fmt.Errorf("workspace: extract %s: %w", name, extractErr)
}
totalBytes += n
count++
contentType := detectContentType(name, destPath)
fs.store.UpsertFile(ctx, &models.WorkspaceFile{
WorkspaceID: w.ID,
Path: name,
IsDirectory: false,
ContentType: contentType,
SizeBytes: n,
SHA256: hash,
})
default:
// Skip symlinks, devices, etc.
continue
}
}
log.Printf("workspace: extracted %d files from tar.gz (%d bytes)", count, totalBytes)
return count, nil
}
// ── Create Archive ──────────────────────────
// CreateArchive packages the workspace into a zip or tar.gz archive.
// Returns a path to the temporary archive file. Caller must remove it when done.
func (fs *FS) CreateArchive(ctx context.Context, w *models.Workspace, format string) (string, error) {
switch format {
case "zip":
return fs.createZip(ctx, w)
case "tar.gz", "tgz":
return fs.createTarGz(ctx, w)
default:
return "", fmt.Errorf("workspace: unsupported archive format: %s", format)
}
}
func (fs *FS) createZip(ctx context.Context, w *models.Workspace) (string, error) {
root := fs.filesDir(w)
tmp, err := os.CreateTemp("", "ws-*.zip")
if err != nil {
return "", err
}
tmpName := tmp.Name()
zw := zip.NewWriter(tmp)
err = filepath.Walk(root, func(abs string, info os.FileInfo, walkErr error) error {
if walkErr != nil {
return walkErr
}
if err := ctx.Err(); err != nil {
return err
}
rel, err := filepath.Rel(root, abs)
if err != nil {
return err
}
if rel == "." {
return nil
}
rel = filepath.ToSlash(rel)
if info.IsDir() {
_, err := zw.Create(rel + "/")
return err
}
header, err := zip.FileInfoHeader(info)
if err != nil {
return err
}
header.Name = rel
header.Method = zip.Deflate
writer, err := zw.CreateHeader(header)
if err != nil {
return err
}
f, err := os.Open(abs)
if err != nil {
return err
}
defer f.Close()
_, err = io.Copy(writer, f)
return err
})
if closeErr := zw.Close(); closeErr != nil && err == nil {
err = closeErr
}
if closeErr := tmp.Close(); closeErr != nil && err == nil {
err = closeErr
}
if err != nil {
os.Remove(tmpName)
return "", fmt.Errorf("workspace: create zip: %w", err)
}
return tmpName, nil
}
func (fs *FS) createTarGz(ctx context.Context, w *models.Workspace) (string, error) {
root := fs.filesDir(w)
tmp, err := os.CreateTemp("", "ws-*.tar.gz")
if err != nil {
return "", err
}
tmpName := tmp.Name()
gw := gzip.NewWriter(tmp)
tw := tar.NewWriter(gw)
err = filepath.Walk(root, func(abs string, info os.FileInfo, walkErr error) error {
if walkErr != nil {
return walkErr
}
if err := ctx.Err(); err != nil {
return err
}
rel, err := filepath.Rel(root, abs)
if err != nil {
return err
}
if rel == "." {
return nil
}
rel = filepath.ToSlash(rel)
header, err := tar.FileInfoHeader(info, "")
if err != nil {
return err
}
header.Name = rel
if err := tw.WriteHeader(header); err != nil {
return err
}
if info.IsDir() {
return nil
}
f, err := os.Open(abs)
if err != nil {
return err
}
defer f.Close()
_, err = io.Copy(tw, f)
return err
})
if closeErr := tw.Close(); closeErr != nil && err == nil {
err = closeErr
}
if closeErr := gw.Close(); closeErr != nil && err == nil {
err = closeErr
}
if closeErr := tmp.Close(); closeErr != nil && err == nil {
err = closeErr
}
if err != nil {
os.Remove(tmpName)
return "", fmt.Errorf("workspace: create tar.gz: %w", err)
}
return tmpName, nil
}
// ── Helpers ─────────────────────────────────
// extractToFile writes content from a reader to a file, creating parent dirs.
// Returns bytes written and SHA256 hash.
func extractToFile(destPath string, r io.Reader, maxSize int64) (int64, string, error) {
if err := os.MkdirAll(filepath.Dir(destPath), 0750); err != nil {
return 0, "", err
}
f, err := os.Create(destPath)
if err != nil {
return 0, "", err
}
defer f.Close()
h := sha256.New()
tee := io.TeeReader(io.LimitReader(r, maxSize+1), h)
n, err := io.Copy(f, tee)
if err != nil {
return n, "", err
}
if n > maxSize {
os.Remove(destPath)
return 0, "", fmt.Errorf("file exceeds max size (%d bytes)", maxSize)
}
return n, hex.EncodeToString(h.Sum(nil)), nil
}
// isUnsafePath returns true if the path contains traversal or unsafe patterns.
func isUnsafePath(p string) bool {
if strings.HasPrefix(p, "/") || strings.HasPrefix(p, "\\") {
return true
}
if strings.Contains(p, "..") {
return true
}
if strings.HasPrefix(p, ".") && !strings.HasPrefix(p, "./") {
// Allow dotfiles like .gitignore, but not .. or hidden dirs as root
// Actually, dotfiles are fine. Let them through.
return false
}
return false
}
// detectCommonPrefix finds a shared directory prefix among file names.
// E.g. ["project/src/a.go", "project/src/b.go"] → "project/"
func detectCommonPrefix(names []string) string {
if len(names) == 0 {
return ""
}
// Check if all files share a common first directory component
var prefix string
for _, name := range names {
idx := strings.IndexByte(name, '/')
if idx < 0 {
// File at root level — no common prefix
return ""
}
dir := name[:idx+1]
if prefix == "" {
prefix = dir
} else if dir != prefix {
return ""
}
}
return prefix
}
// stripPrefix removes the common archive prefix from a file name.
func stripPrefix(name, prefix string) string {
if prefix != "" {
name = strings.TrimPrefix(name, prefix)
}
name = strings.TrimSuffix(name, "/")
name = filepath.ToSlash(name)
return cleanPath(name)
}
// zipFileNames extracts file names from zip entries.
func zipFileNames(files []*zip.File) []string {
names := make([]string, len(files))
for i, f := range files {
names[i] = f.Name
}
return names
}

526
server/workspace/fs.go Normal file
View File

@@ -0,0 +1,526 @@
// 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,
})
}
// 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
}

388
server/workspace/fs_test.go Normal file
View File

@@ -0,0 +1,388 @@
package workspace
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"git.gobha.me/xcaliber/chat-switchboard/models"
)
// ── Path Cleaning Tests ─────────────────────
func TestCleanPath(t *testing.T) {
tests := []struct {
input string
want string
}{
{"src/main.go", "src/main.go"},
{"/src/main.go", "src/main.go"},
{"./src/main.go", "src/main.go"},
{"src/../etc/passwd", "etc/passwd"},
{"../etc/passwd", ""},
{"../..", ""},
{"..", ""},
{".", ""},
{"", ""},
{" src/main.go ", "src/main.go"},
{"src/./main.go", "src/main.go"},
{".gitignore", ".gitignore"},
{".env", ".env"},
{"src/.hidden/file.go", "src/.hidden/file.go"},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got := cleanPath(tt.input)
if got != tt.want {
t.Errorf("cleanPath(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestAbsPathTraversal(t *testing.T) {
tmpDir := t.TempDir()
fs := &FS{basePath: tmpDir}
w := &models.Workspace{
BaseModel: models.BaseModel{ID: "test-workspace"},
}
// Create the files dir so absPath has a real root to resolve against
filesDir := filepath.Join(tmpDir, w.ID, filesSubdir)
os.MkdirAll(filesDir, 0750)
// Valid paths should work
validPaths := []string{
"src/main.go",
"README.md",
".gitignore",
"deeply/nested/path/file.txt",
}
for _, p := range validPaths {
abs, err := fs.absPath(w, p)
if err != nil {
t.Errorf("absPath(%q) unexpected error: %v", p, err)
continue
}
if !strings.HasPrefix(abs, filesDir) {
t.Errorf("absPath(%q) = %s, not under %s", p, abs, filesDir)
}
}
// Traversal attempts should fail
traversalPaths := []string{
"../../../etc/passwd",
"src/../../etc/passwd",
}
for _, p := range traversalPaths {
_, err := fs.absPath(w, p)
if err == nil {
t.Errorf("absPath(%q) should have returned error for traversal", p)
}
}
}
// ── Content Type Detection Tests ────────────
func TestMimeByExtension(t *testing.T) {
tests := []struct {
ext string
want string
}{
{".go", "text/x-go"},
{".py", "text/x-python"},
{".rs", "text/x-rust"},
{".ts", "text/typescript"},
{".sh", "text/x-shellscript"},
{".sql", "text/x-sql"},
{".yaml", "text/yaml"},
{".yml", "text/yaml"},
{".toml", "application/toml"},
{".pl", "text/x-perl"},
{".pm", "text/x-perl"},
{".lua", "text/x-lua"},
{".dockerfile", "text/x-dockerfile"},
{".unknown", ""},
}
for _, tt := range tests {
t.Run(tt.ext, func(t *testing.T) {
got := mimeByExtension(tt.ext)
if got != tt.want {
t.Errorf("mimeByExtension(%q) = %q, want %q", tt.ext, got, tt.want)
}
})
}
}
// ── Unsafe Path Tests ───────────────────────
func TestIsUnsafePath(t *testing.T) {
tests := []struct {
path string
unsafe bool
}{
{"src/main.go", false},
{".gitignore", false},
{".env", false},
{"../etc/passwd", true},
{"/etc/passwd", true},
{"src/../../../etc/passwd", true},
{"\\windows\\system32", true},
}
for _, tt := range tests {
t.Run(tt.path, func(t *testing.T) {
got := isUnsafePath(tt.path)
if got != tt.unsafe {
t.Errorf("isUnsafePath(%q) = %v, want %v", tt.path, got, tt.unsafe)
}
})
}
}
// ── Common Prefix Detection Tests ───────────
func TestDetectCommonPrefix(t *testing.T) {
tests := []struct {
name string
names []string
want string
}{
{
name: "common prefix",
names: []string{"project/src/a.go", "project/src/b.go", "project/README.md"},
want: "project/",
},
{
name: "no common prefix",
names: []string{"src/a.go", "lib/b.go"},
want: "",
},
{
name: "file at root",
names: []string{"project/src/a.go", "Makefile"},
want: "",
},
{
name: "empty",
names: []string{},
want: "",
},
{
name: "single dir",
names: []string{"myproject/file.txt"},
want: "myproject/",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := detectCommonPrefix(tt.names)
if got != tt.want {
t.Errorf("detectCommonPrefix(%v) = %q, want %q", tt.names, got, tt.want)
}
})
}
}
// ── Integration: Write + Read Round-Trip ────
type mockWorkspaceStore struct {
files map[string]*models.WorkspaceFile
}
func newMockStore() *mockWorkspaceStore {
return &mockWorkspaceStore{files: make(map[string]*models.WorkspaceFile)}
}
func (m *mockWorkspaceStore) Create(ctx context.Context, w *models.Workspace) error { return nil }
func (m *mockWorkspaceStore) GetByID(ctx context.Context, id string) (*models.Workspace, error) {
return nil, nil
}
func (m *mockWorkspaceStore) Update(ctx context.Context, id string, patch models.WorkspacePatch) error {
return nil
}
func (m *mockWorkspaceStore) Delete(ctx context.Context, id string) error { return nil }
func (m *mockWorkspaceStore) GetByOwner(ctx context.Context, ownerType, ownerID string) (*models.Workspace, error) {
return nil, nil
}
func (m *mockWorkspaceStore) ListByOwner(ctx context.Context, ownerType, ownerID string) ([]models.Workspace, error) {
return nil, nil
}
func (m *mockWorkspaceStore) GetStats(ctx context.Context, workspaceID string) (*models.WorkspaceStats, error) {
return nil, nil
}
func (m *mockWorkspaceStore) UpsertFile(ctx context.Context, f *models.WorkspaceFile) error {
key := f.WorkspaceID + ":" + f.Path
m.files[key] = f
return nil
}
func (m *mockWorkspaceStore) DeleteFile(ctx context.Context, workspaceID, path string) error {
delete(m.files, workspaceID+":"+path)
return nil
}
func (m *mockWorkspaceStore) DeleteFilesByPrefix(ctx context.Context, workspaceID, prefix string) error {
for k := range m.files {
if strings.HasPrefix(k, workspaceID+":"+prefix) {
delete(m.files, k)
}
}
return nil
}
func (m *mockWorkspaceStore) GetFile(ctx context.Context, workspaceID, path string) (*models.WorkspaceFile, error) {
f, ok := m.files[workspaceID+":"+path]
if !ok {
return nil, os.ErrNotExist
}
return f, nil
}
func (m *mockWorkspaceStore) ListFiles(ctx context.Context, workspaceID, prefix string, recursive bool) ([]models.WorkspaceFile, error) {
var out []models.WorkspaceFile
for _, f := range m.files {
if f.WorkspaceID == workspaceID {
if prefix == "" || strings.HasPrefix(f.Path, prefix) {
out = append(out, *f)
}
}
}
return out, nil
}
func (m *mockWorkspaceStore) DeleteAllFiles(ctx context.Context, workspaceID string) error {
for k, f := range m.files {
if f.WorkspaceID == workspaceID {
delete(m.files, k)
}
}
return nil
}
func TestWriteReadRoundTrip(t *testing.T) {
tmpDir := t.TempDir()
mock := newMockStore()
wfs := NewFS(tmpDir, mock)
w := &models.Workspace{
BaseModel: models.BaseModel{ID: "test-ws"},
Status: "active",
}
if err := wfs.CreateDir(w); err != nil {
t.Fatalf("CreateDir: %v", err)
}
ctx := context.Background()
content := "package main\n\nfunc main() {}\n"
// Write
err := wfs.WriteFile(ctx, w, "src/main.go", strings.NewReader(content), int64(len(content)))
if err != nil {
t.Fatalf("WriteFile: %v", err)
}
// Verify DB index was updated
f, ok := mock.files["test-ws:src/main.go"]
if !ok {
t.Fatal("expected file in mock store index")
}
if f.ContentType != "text/x-go" {
t.Errorf("content_type = %q, want %q", f.ContentType, "text/x-go")
}
if f.SizeBytes != int64(len(content)) {
t.Errorf("size_bytes = %d, want %d", f.SizeBytes, len(content))
}
if f.SHA256 == "" {
t.Error("expected non-empty sha256")
}
// Read back
rc, size, err := wfs.ReadFile(ctx, w, "src/main.go")
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
defer rc.Close()
if size != int64(len(content)) {
t.Errorf("read size = %d, want %d", size, len(content))
}
buf := make([]byte, size)
if _, err := rc.Read(buf); err != nil {
t.Fatalf("Read: %v", err)
}
if string(buf) != content {
t.Errorf("content = %q, want %q", string(buf), content)
}
}
func TestDeleteFile(t *testing.T) {
tmpDir := t.TempDir()
mock := newMockStore()
wfs := NewFS(tmpDir, mock)
w := &models.Workspace{
BaseModel: models.BaseModel{ID: "test-ws"},
}
wfs.CreateDir(w)
ctx := context.Background()
content := "hello"
wfs.WriteFile(ctx, w, "test.txt", strings.NewReader(content), int64(len(content)))
// Delete
err := wfs.DeleteFile(ctx, w, "test.txt", false)
if err != nil {
t.Fatalf("DeleteFile: %v", err)
}
// Verify removed from index
if _, ok := mock.files["test-ws:test.txt"]; ok {
t.Error("expected file removed from index")
}
// Verify removed from filesystem
abs := filepath.Join(tmpDir, w.ID, filesSubdir, "test.txt")
if _, err := os.Stat(abs); !os.IsNotExist(err) {
t.Error("expected file removed from filesystem")
}
}
func TestMkdir(t *testing.T) {
tmpDir := t.TempDir()
mock := newMockStore()
wfs := NewFS(tmpDir, mock)
w := &models.Workspace{
BaseModel: models.BaseModel{ID: "test-ws"},
}
wfs.CreateDir(w)
ctx := context.Background()
err := wfs.Mkdir(ctx, w, "src/pkg/handlers")
if err != nil {
t.Fatalf("Mkdir: %v", err)
}
// Verify on disk
abs := filepath.Join(tmpDir, w.ID, filesSubdir, "src/pkg/handlers")
info, err := os.Stat(abs)
if err != nil {
t.Fatalf("stat: %v", err)
}
if !info.IsDir() {
t.Error("expected directory")
}
// Verify in index
f, ok := mock.files["test-ws:src/pkg/handlers"]
if !ok {
t.Fatal("expected dir in mock store")
}
if !f.IsDirectory {
t.Error("expected is_directory = true")
}
}