package server import ( "bytes" "errors" "fmt" "io" "io/fs" "net/http" "os" "path" "path/filepath" "slices" "strings" "unicode/utf8" "g.fc.run/ai/byre/internal/api" "g.fc.run/ai/byre/internal/git" ) const ( maxFileBytes = 256 << 10 maxDirEntries = 5000 ) func (s *Server) GetTaskDir(w http.ResponseWriter, r *http.Request, taskID string, params api.GetTaskDirParams) { t, _, ok := s.activeTask(w, r, taskID) if !ok { return } rel := "" if params.Path != nil { rel = *params.Path } name, ok := worktreeName(w, rel) if !ok { return } root, err := os.OpenRoot(t.WorktreePath) if err != nil { writeError(w, http.StatusInternalServerError, err) return } defer root.Close() f, err := root.Open(name) if err != nil { writePathError(w, err) return } defer f.Close() fi, err := f.Stat() if err != nil { writeError(w, http.StatusInternalServerError, err) return } if !fi.IsDir() { writeError(w, http.StatusBadRequest, fmt.Errorf("%s is not a directory", rel)) return } des, err := f.ReadDir(-1) if err != nil { writeError(w, http.StatusInternalServerError, err) return } entries := []api.DirEntry{} for _, de := range des { if de.Name() == ".git" { continue } entries = append(entries, dirEntry(root, path.Join(name, de.Name()), de)) } slices.SortFunc(entries, func(a, b api.DirEntry) int { if (a.Type == api.DirEntryTypeDir) != (b.Type == api.DirEntryTypeDir) { if a.Type == api.DirEntryTypeDir { return -1 } return 1 } return strings.Compare(a.Name, b.Name) }) listing := api.DirListing{Entries: entries} if name != "." { listing.Path = name } if len(entries) > maxDirEntries { listing.Entries = entries[:maxDirEntries] listing.Truncated = true } real, err := realDir(t.WorktreePath, name) if err != nil { writeError(w, http.StatusInternalServerError, err) return } paths := []string{} for _, e := range listing.Entries { paths = append(paths, path.Join(real, e.Name)) } ignored, err := git.Ignored(t.WorktreePath, paths) if err != nil { writeError(w, http.StatusInternalServerError, err) return } for i, p := range paths { listing.Entries[i].Ignored = ignored[p] } writeJSON(w, http.StatusOK, listing) } func realDir(worktree, name string) (string, error) { root, err := filepath.EvalSymlinks(worktree) if err != nil { return "", err } dir, err := filepath.EvalSymlinks(filepath.Join(worktree, name)) if err != nil { return "", err } rel, err := filepath.Rel(root, dir) if err != nil { return "", err } return filepath.ToSlash(rel), nil } func dirEntry(root *os.Root, name string, de fs.DirEntry) api.DirEntry { e := api.DirEntry{Name: de.Name(), Type: api.DirEntryTypeOther} var fi fs.FileInfo var err error if de.Type()&fs.ModeSymlink != 0 { e.Symlink = true fi, err = root.Stat(name) } else { fi, err = de.Info() } if err != nil { return e } switch { case fi.IsDir(): e.Type = api.DirEntryTypeDir case fi.Mode().IsRegular(): e.Type = api.DirEntryTypeFile e.Size = fi.Size() } return e } func (s *Server) GetTaskFile(w http.ResponseWriter, r *http.Request, taskID string, params api.GetTaskFileParams) { t, _, ok := s.activeTask(w, r, taskID) if !ok { return } if params.Path == "" { writeError(w, http.StatusBadRequest, errors.New("path is required")) return } name, ok := worktreeName(w, params.Path) if !ok { return } root, err := os.OpenRoot(t.WorktreePath) if err != nil { writeError(w, http.StatusInternalServerError, err) return } defer root.Close() fi, err := root.Stat(name) if err != nil { writePathError(w, err) return } if !fi.Mode().IsRegular() { writeError(w, http.StatusBadRequest, fmt.Errorf("%s is not a regular file", params.Path)) return } f, err := root.Open(name) if err != nil { writePathError(w, err) return } defer f.Close() b, err := io.ReadAll(io.LimitReader(f, maxFileBytes+1)) if err != nil { writeError(w, http.StatusInternalServerError, err) return } fc := api.FileContent{Path: name, Size: fi.Size()} if len(b) > maxFileBytes { fc.Truncated = true b = b[:maxFileBytes] for n := 0; n < utf8.UTFMax-1 && len(b) > 0 && !utf8.Valid(b); n++ { b = b[:len(b)-1] } } if bytes.IndexByte(b, 0) >= 0 || !utf8.Valid(b) { fc.Binary = true writeJSON(w, http.StatusOK, fc) return } fc.Text = string(b) writeJSON(w, http.StatusOK, fc) } func worktreeName(w http.ResponseWriter, rel string) (string, bool) { if rel == "" { return ".", true } name := filepath.FromSlash(rel) if !filepath.IsLocal(name) { writeError(w, http.StatusBadRequest, fmt.Errorf("%s is not a path inside the worktree", rel)) return "", false } name = filepath.ToSlash(filepath.Clean(name)) if slices.Contains(strings.Split(name, "/"), ".git") { writeError(w, http.StatusBadRequest, fmt.Errorf("%s is inside .git", rel)) return "", false } return name, true } func writePathError(w http.ResponseWriter, err error) { if errors.Is(err, fs.ErrNotExist) { writeError(w, http.StatusNotFound, err) return } writeError(w, http.StatusBadRequest, err) }