package handlers import ( "context" "encoding/json" "errors" "fmt" "io/fs" "os" "path/filepath" "strings" ) // FsReadHandler handles fs/read_text_file (spec ยง5.4). type FsReadHandler struct{} // NewFsReadHandler constructs an FsReadHandler. func NewFsReadHandler() *FsReadHandler { return &FsReadHandler{} } // fsReadParams is the ACP-protocol input for fs/read_text_file. type fsReadParams struct { SessionID string `json:"sessionId"` Path string `json:"path"` Line *int `json:"line,omitempty"` Limit *int `json:"limit,omitempty"` } type fsReadResult struct { Content string `json:"content"` } // Handle reads a file inside sess.CwdPath, returning content (with optional // line/limit slicing). Returns FsError for path-escape or read failure. func (h *FsReadHandler) Handle(_ context.Context, sess SessionContext, paramsJSON json.RawMessage) FsResult { var p fsReadParams if err := json.Unmarshal(paramsJSON, &p); err != nil { return FsResult{Err: &FsError{Code: -32602, Message: "invalid params: " + err.Error()}} } abs, err := resolveSafePath(sess.CwdPath, p.Path) if err != nil { return FsResult{Err: &FsError{Code: -32001, Message: err.Error()}} } data, err := os.ReadFile(abs) if err != nil { if errors.Is(err, fs.ErrNotExist) { return FsResult{Err: &FsError{Code: -32603, Message: "file not found"}} } return FsResult{Err: &FsError{Code: -32603, Message: "read: " + err.Error()}} } content := string(data) if p.Line != nil || p.Limit != nil { content = sliceLines(content, p.Line, p.Limit) } out, _ := json.Marshal(fsReadResult{Content: content}) return FsResult{OK: out} } // sliceLines returns lines[line:line+limit] joined by "\n". // line is 0-based; limit caps the line count. func sliceLines(s string, line, limit *int) string { lines := strings.Split(s, "\n") start := 0 if line != nil && *line > 0 { start = *line } if start > len(lines) { start = len(lines) } end := len(lines) if limit != nil && *limit >= 0 { if start+*limit < end { end = start + *limit } } return strings.Join(lines[start:end], "\n") } // resolveSafePath duplicates the logic from internal/acp.ResolveSafePath to // avoid an import cycle (handlers is a sub-package and acp imports handlers). func resolveSafePath(cwd, requested string) (string, error) { if !filepath.IsAbs(cwd) { return "", fmt.Errorf("cwd must be absolute") } cleanCwd := filepath.Clean(cwd) var abs string if filepath.IsAbs(requested) { abs = filepath.Clean(requested) } else { abs = filepath.Clean(filepath.Join(cleanCwd, requested)) } if !hasPathPrefix(abs, cleanCwd) { return "", fmt.Errorf("path outside worktree") } if real, err := filepath.EvalSymlinks(abs); err == nil { if !hasPathPrefix(real, cleanCwd) { return "", fmt.Errorf("path resolves outside worktree via symlink") } } return abs, nil } func hasPathPrefix(abs, base string) bool { if abs == base { return true } sep := string(filepath.Separator) return strings.HasPrefix(abs, base+sep) }