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, rel, err := l.resolve(path) if err != nil { return err } if rel == "." { return util.New(util.CodeBadRequest, "cannot remove workspace root") } 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 }