picoclaw/pkg/tools/filesystem.go

677 lines
18 KiB
Go

package tools
import (
"bytes"
"context"
"fmt"
"io/fs"
"os"
"os/exec"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
"github.com/sipeed/picoclaw/pkg/fileutil"
)
// validatePath ensures the given path is within the workspace if restrict is true.
func validatePath(path, workspace string, restrict bool) (string, error) {
if workspace == "" {
return path, fmt.Errorf("workspace is not defined")
}
absWorkspace, err := filepath.Abs(workspace)
if err != nil {
return "", fmt.Errorf("failed to resolve workspace path: %w", err)
}
var absPath string
if filepath.IsAbs(path) {
absPath = filepath.Clean(path)
} else {
absPath, err = filepath.Abs(filepath.Join(absWorkspace, path))
if err != nil {
return "", fmt.Errorf("failed to resolve file path: %w", err)
}
}
if restrict {
if !isWithinWorkspace(absPath, absWorkspace) {
return "", fmt.Errorf("access denied: path is outside the workspace")
}
var resolved string
workspaceReal := absWorkspace
if resolved, err = filepath.EvalSymlinks(absWorkspace); err == nil {
workspaceReal = resolved
}
if resolved, err = filepath.EvalSymlinks(absPath); err == nil {
if !isWithinWorkspace(resolved, workspaceReal) {
return "", fmt.Errorf("access denied: symlink resolves outside workspace")
}
} else if os.IsNotExist(err) {
var parentResolved string
if parentResolved, err = resolveExistingAncestor(filepath.Dir(absPath)); err == nil {
if !isWithinWorkspace(parentResolved, workspaceReal) {
return "", fmt.Errorf("access denied: symlink resolves outside workspace")
}
} else if !os.IsNotExist(err) {
return "", fmt.Errorf("failed to resolve path: %w", err)
}
} else {
return "", fmt.Errorf("failed to resolve path: %w", err)
}
}
return absPath, nil
}
func resolveExistingAncestor(path string) (string, error) {
for current := filepath.Clean(path); ; current = filepath.Dir(current) {
if resolved, err := filepath.EvalSymlinks(current); err == nil {
return resolved, nil
} else if !os.IsNotExist(err) {
return "", err
}
if filepath.Dir(current) == current {
return "", os.ErrNotExist
}
}
}
func isWithinWorkspace(candidate, workspace string) bool {
rel, err := filepath.Rel(filepath.Clean(workspace), filepath.Clean(candidate))
return err == nil && filepath.IsLocal(rel)
}
type ReadFileTool struct {
fs fileSystem
}
func NewReadFileTool(workspace string, restrict bool, allowPaths ...[]*regexp.Regexp) *ReadFileTool {
var patterns []*regexp.Regexp
if len(allowPaths) > 0 {
patterns = allowPaths[0]
}
return &ReadFileTool{fs: buildFs(workspace, restrict, patterns)}
}
func (t *ReadFileTool) Name() string {
return "read_file"
}
func (t *ReadFileTool) Description() string {
return "Read the contents of a file"
}
func (t *ReadFileTool) Parameters() map[string]any {
return map[string]any{
"type": "object",
"properties": map[string]any{
"path": map[string]any{
"type": "string",
"description": "Path to the file to read",
},
},
"required": []string{"path"},
}
}
func (t *ReadFileTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
path, ok := args["path"].(string)
if !ok {
return ErrorResult("path is required")
}
content, err := t.fs.ReadFile(path)
if err != nil {
return ErrorResult(err.Error())
}
if looksLikePDF(path, content) {
extracted, method, extractErr := extractPDFText(ctx, path)
if extractErr != nil {
return ErrorResult(fmt.Sprintf("failed to extract PDF text: %v", extractErr))
}
if strings.TrimSpace(extracted) != "" {
return NewToolResult(fmt.Sprintf("[pdf text extracted via %s]\n%s", method, extracted))
}
return ErrorResult("failed to extract PDF text: empty result")
}
return NewToolResult(string(content))
}
func looksLikePDF(path string, content []byte) bool {
if strings.EqualFold(filepath.Ext(strings.TrimSpace(path)), ".pdf") {
return true
}
return bytes.HasPrefix(content, []byte("%PDF-"))
}
func extractPDFText(ctx context.Context, path string) (string, string, error) {
if text, err := extractPDFTextWithPoppler(ctx, path); err == nil && isUsefulPDFText(text) {
return text, "pdftotext", nil
}
if text, err := extractPDFTextWithOCR(ctx, path); err == nil && isUsefulPDFText(text) {
return text, "ocr", nil
}
if _, err := exec.LookPath("pdftotext"); err != nil {
return "", "", fmt.Errorf("pdftotext not available; install poppler-utils")
}
if _, err := exec.LookPath("pdftoppm"); err != nil {
return "", "", fmt.Errorf("pdftoppm not available; install poppler-utils")
}
if _, err := exec.LookPath("tesseract"); err != nil {
return "", "", fmt.Errorf("tesseract not available")
}
return "", "", fmt.Errorf("no usable text could be extracted from the PDF")
}
func extractPDFTextWithPoppler(ctx context.Context, path string) (string, error) {
if _, err := exec.LookPath("pdftotext"); err != nil {
return "", err
}
tmpFile, err := os.CreateTemp("", "picoclaw-pdf-*.txt")
if err != nil {
return "", err
}
tmpPath := tmpFile.Name()
_ = tmpFile.Close()
defer os.Remove(tmpPath)
cmd := exec.CommandContext(ctx, "pdftotext", "-layout", "-nopgbrk", path, tmpPath)
if out, err := cmd.CombinedOutput(); err != nil {
return "", fmt.Errorf("pdftotext failed: %v: %s", err, strings.TrimSpace(string(out)))
}
data, err := os.ReadFile(tmpPath)
if err != nil {
return "", err
}
return strings.TrimSpace(string(data)), nil
}
func extractPDFTextWithOCR(ctx context.Context, path string) (string, error) {
if _, err := exec.LookPath("pdftoppm"); err != nil {
return "", err
}
if _, err := exec.LookPath("tesseract"); err != nil {
return "", err
}
tmpDir, err := os.MkdirTemp("", "picoclaw-pdf-ocr-*")
if err != nil {
return "", err
}
defer os.RemoveAll(tmpDir)
prefix := filepath.Join(tmpDir, "page")
cmd := exec.CommandContext(ctx, "pdftoppm", "-png", path, prefix)
if out, err := cmd.CombinedOutput(); err != nil {
return "", fmt.Errorf("pdftoppm failed: %v: %s", err, strings.TrimSpace(string(out)))
}
pages, err := filepath.Glob(prefix + "-*.png")
if err != nil {
return "", err
}
if len(pages) == 0 {
return "", fmt.Errorf("no pages rendered for OCR")
}
sort.Strings(pages)
langs := preferredTesseractOCRLanguages()
var parts []string
for _, page := range pages {
args := []string{page, "stdout", "--psm", "6"}
if langs != "" {
args = append(args, "-l", langs)
}
cmd := exec.CommandContext(ctx, "tesseract", args...)
out, err := cmd.CombinedOutput()
if err != nil {
return "", fmt.Errorf("tesseract failed on %s: %v: %s", filepath.Base(page), err, strings.TrimSpace(string(out)))
}
text := strings.TrimSpace(string(out))
if text != "" {
parts = append(parts, text)
}
}
return strings.Join(parts, "\n\n"), nil
}
func preferredTesseractOCRLanguages() string {
override := strings.TrimSpace(os.Getenv("PICO_TESSERACT_LANGS"))
if override != "" {
return normalizeTesseractLangSpec(override)
}
available := installedTesseractLanguages()
if len(available) == 0 {
return ""
}
preferred := []string{"eng", "nld", "ron", "spa", "fra", "deu", "ita", "por", "pol", "tur"}
seen := map[string]bool{}
var picks []string
for _, lang := range preferred {
if containsString(available, lang) && !seen[lang] {
seen[lang] = true
picks = append(picks, lang)
}
}
if len(picks) > 0 {
return strings.Join(picks, "+")
}
for _, lang := range available {
if lang == "" || lang == "osd" {
continue
}
return lang
}
return ""
}
func installedTesseractLanguages() []string {
cmd := exec.Command("tesseract", "--list-langs")
out, err := cmd.Output()
if err != nil {
return nil
}
var langs []string
seen := map[string]bool{}
for _, line := range strings.Split(string(out), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "List of available languages") || line == "osd" {
continue
}
if !seen[line] {
seen[line] = true
langs = append(langs, line)
}
}
return langs
}
func normalizeTesseractLangSpec(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
raw = strings.NewReplacer(",", "+", " ", "+", ";", "+").Replace(raw)
parts := strings.Split(raw, "+")
seen := map[string]bool{}
var cleaned []string
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" || part == "osd" || seen[part] {
continue
}
seen[part] = true
cleaned = append(cleaned, part)
}
return strings.Join(cleaned, "+")
}
func containsString(items []string, needle string) bool {
for _, item := range items {
if item == needle {
return true
}
}
return false
}
func isUsefulPDFText(text string) bool {
trimmed := strings.TrimSpace(text)
if len(trimmed) < 20 {
return false
}
words := len(strings.Fields(trimmed))
if words >= 8 {
return true
}
letters := 0
for _, r := range trimmed {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') {
letters++
}
}
return letters >= 20
}
type WriteFileTool struct {
fs fileSystem
}
func NewWriteFileTool(workspace string, restrict bool, allowPaths ...[]*regexp.Regexp) *WriteFileTool {
var patterns []*regexp.Regexp
if len(allowPaths) > 0 {
patterns = allowPaths[0]
}
return &WriteFileTool{fs: buildFs(workspace, restrict, patterns)}
}
func (t *WriteFileTool) Name() string {
return "write_file"
}
func (t *WriteFileTool) Description() string {
return "Write content to a file"
}
func (t *WriteFileTool) Parameters() map[string]any {
return map[string]any{
"type": "object",
"properties": map[string]any{
"path": map[string]any{
"type": "string",
"description": "Path to the file to write",
},
"content": map[string]any{
"type": "string",
"description": "Content to write to the file",
},
},
"required": []string{"path", "content"},
}
}
func (t *WriteFileTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
path, ok := args["path"].(string)
if !ok {
return ErrorResult("path is required")
}
content, ok := args["content"].(string)
if !ok {
return ErrorResult("content is required")
}
if err := t.fs.WriteFile(path, []byte(content)); err != nil {
return ErrorResult(err.Error())
}
return SilentResult(fmt.Sprintf("File written: %s", path))
}
type ListDirTool struct {
fs fileSystem
}
func NewListDirTool(workspace string, restrict bool, allowPaths ...[]*regexp.Regexp) *ListDirTool {
var patterns []*regexp.Regexp
if len(allowPaths) > 0 {
patterns = allowPaths[0]
}
return &ListDirTool{fs: buildFs(workspace, restrict, patterns)}
}
func (t *ListDirTool) Name() string {
return "list_dir"
}
func (t *ListDirTool) Description() string {
return "List files and directories in a path"
}
func (t *ListDirTool) Parameters() map[string]any {
return map[string]any{
"type": "object",
"properties": map[string]any{
"path": map[string]any{
"type": "string",
"description": "Path to list",
},
},
"required": []string{"path"},
}
}
func (t *ListDirTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
path, ok := args["path"].(string)
if !ok {
path = "."
}
entries, err := t.fs.ReadDir(path)
if err != nil {
return ErrorResult(fmt.Sprintf("failed to read directory: %v", err))
}
return formatDirEntries(entries)
}
func formatDirEntries(entries []os.DirEntry) *ToolResult {
var result strings.Builder
for _, entry := range entries {
if entry.IsDir() {
result.WriteString("DIR: " + entry.Name() + "\n")
} else {
result.WriteString("FILE: " + entry.Name() + "\n")
}
}
return NewToolResult(result.String())
}
// fileSystem abstracts reading, writing, and listing files, allowing both
// unrestricted (host filesystem) and sandbox (os.Root) implementations to share the same polymorphic interface.
type fileSystem interface {
ReadFile(path string) ([]byte, error)
WriteFile(path string, data []byte) error
ReadDir(path string) ([]os.DirEntry, error)
}
// hostFs is an unrestricted fileReadWriter that operates directly on the host filesystem.
type hostFs struct{}
func (h *hostFs) ReadFile(path string) ([]byte, error) {
content, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil, fmt.Errorf("failed to read file: file not found: %w", err)
}
if os.IsPermission(err) {
return nil, fmt.Errorf("failed to read file: access denied: %w", err)
}
return nil, fmt.Errorf("failed to read file: %w", err)
}
return content, nil
}
func (h *hostFs) ReadDir(path string) ([]os.DirEntry, error) {
return os.ReadDir(path)
}
func (h *hostFs) WriteFile(path string, data []byte) error {
// Use unified atomic write utility with explicit sync for flash storage reliability.
// Using 0o600 (owner read/write only) for secure default permissions.
return fileutil.WriteFileAtomic(path, data, 0o600)
}
// sandboxFs is a sandboxed fileSystem that operates within a strictly defined workspace using os.Root.
type sandboxFs struct {
workspace string
}
func (r *sandboxFs) execute(path string, fn func(root *os.Root, relPath string) error) error {
if r.workspace == "" {
return fmt.Errorf("workspace is not defined")
}
root, err := os.OpenRoot(r.workspace)
if err != nil {
return fmt.Errorf("failed to open workspace: %w", err)
}
defer root.Close()
relPath, err := getSafeRelPath(r.workspace, path)
if err != nil {
return err
}
return fn(root, relPath)
}
func (r *sandboxFs) ReadFile(path string) ([]byte, error) {
var content []byte
err := r.execute(path, func(root *os.Root, relPath string) error {
fileContent, err := root.ReadFile(relPath)
if err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("failed to read file: file not found: %w", err)
}
// os.Root returns "escapes from parent" for paths outside the root
if os.IsPermission(err) || strings.Contains(err.Error(), "escapes from parent") ||
strings.Contains(err.Error(), "permission denied") {
return fmt.Errorf("failed to read file: access denied: %w", err)
}
return fmt.Errorf("failed to read file: %w", err)
}
content = fileContent
return nil
})
return content, err
}
func (r *sandboxFs) WriteFile(path string, data []byte) error {
return r.execute(path, func(root *os.Root, relPath string) error {
dir := filepath.Dir(relPath)
if dir != "." && dir != "/" {
if err := root.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("failed to create parent directories: %w", err)
}
}
// Use atomic write pattern with explicit sync for flash storage reliability.
// Using 0o600 (owner read/write only) for secure default permissions.
tmpRelPath := fmt.Sprintf(".tmp-%d-%d", os.Getpid(), time.Now().UnixNano())
tmpFile, err := root.OpenFile(tmpRelPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err != nil {
root.Remove(tmpRelPath)
return fmt.Errorf("failed to open temp file: %w", err)
}
if _, err := tmpFile.Write(data); err != nil {
tmpFile.Close()
root.Remove(tmpRelPath)
return fmt.Errorf("failed to write temp file: %w", err)
}
// CRITICAL: Force sync to storage medium before rename.
// This ensures data is physically written to disk, not just cached.
if err := tmpFile.Sync(); err != nil {
tmpFile.Close()
root.Remove(tmpRelPath)
return fmt.Errorf("failed to sync temp file: %w", err)
}
if err := tmpFile.Close(); err != nil {
root.Remove(tmpRelPath)
return fmt.Errorf("failed to close temp file: %w", err)
}
if err := root.Rename(tmpRelPath, relPath); err != nil {
root.Remove(tmpRelPath)
return fmt.Errorf("failed to rename temp file over target: %w", err)
}
// Sync directory to ensure rename is durable
if dirFile, err := root.Open("."); err == nil {
_ = dirFile.Sync()
dirFile.Close()
}
return nil
})
}
func (r *sandboxFs) ReadDir(path string) ([]os.DirEntry, error) {
var entries []os.DirEntry
err := r.execute(path, func(root *os.Root, relPath string) error {
dirEntries, err := fs.ReadDir(root.FS(), relPath)
if err != nil {
return err
}
entries = dirEntries
return nil
})
return entries, err
}
// whitelistFs wraps a sandboxFs and allows access to specific paths outside
// the workspace when they match any of the provided patterns.
type whitelistFs struct {
sandbox *sandboxFs
host hostFs
patterns []*regexp.Regexp
}
func (w *whitelistFs) matches(path string) bool {
for _, p := range w.patterns {
if p.MatchString(path) {
return true
}
}
return false
}
func (w *whitelistFs) ReadFile(path string) ([]byte, error) {
if w.matches(path) {
return w.host.ReadFile(path)
}
return w.sandbox.ReadFile(path)
}
func (w *whitelistFs) WriteFile(path string, data []byte) error {
if w.matches(path) {
return w.host.WriteFile(path, data)
}
return w.sandbox.WriteFile(path, data)
}
func (w *whitelistFs) ReadDir(path string) ([]os.DirEntry, error) {
if w.matches(path) {
return w.host.ReadDir(path)
}
return w.sandbox.ReadDir(path)
}
// buildFs returns the appropriate fileSystem implementation based on restriction
// settings and optional path whitelist patterns.
func buildFs(workspace string, restrict bool, patterns []*regexp.Regexp) fileSystem {
if !restrict {
return &hostFs{}
}
sandbox := &sandboxFs{workspace: workspace}
if len(patterns) > 0 {
return &whitelistFs{sandbox: sandbox, patterns: patterns}
}
return sandbox
}
// Helper to get a safe relative path for os.Root usage
func getSafeRelPath(workspace, path string) (string, error) {
if workspace == "" {
return "", fmt.Errorf("workspace is not defined")
}
rel := filepath.Clean(path)
if filepath.IsAbs(rel) {
var err error
rel, err = filepath.Rel(workspace, rel)
if err != nil {
return "", fmt.Errorf("failed to calculate relative path: %w", err)
}
}
if !filepath.IsLocal(rel) {
return "", fmt.Errorf("path escapes workspace: %s", path)
}
return rel, nil
}