Skip to content

Commit ab3a8d0

Browse files
committed
feat: add file drag-and-drop support for images and PDFs
- Add ParsePastedFiles() to detect file paths from paste events - Support Unix (space-separated with escaping) and Windows (quoted) formats - Validate file types (PNG, JPG, GIF, WebP, BMP, SVG, PDF) - Enforce 5MB size limit per file (>= boundary) - Add visual file type indicators with emoji icons - Integrate with existing attachment system - Handle edge cases (null chars, trailing backslash, etc.) - Add comprehensive test coverage Security hardening: - Add validateFilePath() to reject path traversal (..) and symlinks - Check for '..' BEFORE filepath.Clean() to prevent bypass - Use os.Lstat() instead of os.Stat() in all file validation paths - Return errors from addFileAttachment() and AttachFile() - Return error notification on failed attach instead of opening file picker - Eliminate TOCTOU by removing redundant allFilesValid() pre-check; AttachFile() is the single validation point with rollback on failure - Strip trailing backslash as malformed input - Add TestValidateFilePath, TestValidateFilePath_TraversalBeforeClean, and TestAddFileAttachment_SizeLimit Assisted-By: cagent
1 parent d7f1f6e commit ab3a8d0

File tree

5 files changed

+609
-25
lines changed

5 files changed

+609
-25
lines changed

pkg/tui/components/editor/editor.go

Lines changed: 64 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ type Editor interface {
7373
// InsertText inserts text at the current cursor position
7474
InsertText(text string)
7575
// AttachFile adds a file as an attachment and inserts @filepath into the editor
76-
AttachFile(filePath string)
76+
AttachFile(filePath string) error
7777
Cleanup()
7878
GetSize() (width, height int)
7979
BannerHeight() int
@@ -690,7 +690,9 @@ func (e *editor) Update(msg tea.Msg) (layout.Model, tea.Cmd) {
690690
}
691691
// Track file references when using @ completion (but not paste placeholders)
692692
if e.currentCompletion != nil && e.currentCompletion.Trigger() == "@" && !strings.HasPrefix(msg.Value, "@paste-") {
693-
e.addFileAttachment(msg.Value)
693+
if err := e.addFileAttachment(msg.Value); err != nil {
694+
slog.Warn("failed to add file attachment from completion", "value", msg.Value, "error", err)
695+
}
694696
}
695697
e.clearSuggestion()
696698
return e, nil
@@ -1287,14 +1289,17 @@ func (e *editor) InsertText(text string) {
12871289
}
12881290

12891291
// AttachFile adds a file as an attachment and inserts @filepath into the editor
1290-
func (e *editor) AttachFile(filePath string) {
1292+
func (e *editor) AttachFile(filePath string) error {
12911293
placeholder := "@" + filePath
1292-
e.addFileAttachment(placeholder)
1294+
if err := e.addFileAttachment(placeholder); err != nil {
1295+
return fmt.Errorf("failed to attach %s: %w", filePath, err)
1296+
}
12931297
currentValue := e.textarea.Value()
12941298
e.textarea.SetValue(currentValue + placeholder + " ")
12951299
e.textarea.MoveToEnd()
12961300
e.userTyped = true
12971301
e.updateAttachmentBanner()
1302+
return nil
12981303
}
12991304

13001305
// tryAddFileRef checks if word is a valid @filepath and adds it as attachment.
@@ -1315,33 +1320,41 @@ func (e *editor) tryAddFileRef(word string) {
13151320
return // not a path-like reference (e.g., @username)
13161321
}
13171322

1318-
e.addFileAttachment(word)
1323+
if err := e.addFileAttachment(word); err != nil {
1324+
slog.Debug("speculative file ref not valid", "word", word, "error", err)
1325+
}
13191326
}
13201327

13211328
// addFileAttachment adds a file reference as an attachment if valid.
13221329
// The path is resolved to an absolute path so downstream consumers
13231330
// (e.g. processFileAttachment) always receive a fully qualified path.
1324-
func (e *editor) addFileAttachment(placeholder string) {
1331+
func (e *editor) addFileAttachment(placeholder string) error {
13251332
path := strings.TrimPrefix(placeholder, "@")
13261333

13271334
// Resolve to absolute path so the attachment carries a fully qualified
13281335
// path regardless of the working directory at send time.
13291336
absPath, err := filepath.Abs(path)
13301337
if err != nil {
1331-
slog.Warn("skipping attachment: cannot resolve path", "path", path, "error", err)
1332-
return
1338+
return fmt.Errorf("cannot resolve path %s: %w", path, err)
13331339
}
13341340

1335-
// Check if it's an existing file (not directory)
1336-
info, err := os.Stat(absPath)
1337-
if err != nil || info.IsDir() {
1338-
return
1341+
info, err := validateFilePath(absPath)
1342+
if err != nil {
1343+
return fmt.Errorf("invalid file path %s: %w", absPath, err)
1344+
}
1345+
if info.IsDir() {
1346+
return fmt.Errorf("path is a directory: %s", absPath)
1347+
}
1348+
1349+
const maxFileSize = 5 * 1024 * 1024
1350+
if info.Size() >= maxFileSize {
1351+
return fmt.Errorf("file too large: %s (%s)", absPath, units.HumanSize(float64(info.Size())))
13391352
}
13401353

13411354
// Avoid duplicates
13421355
for _, att := range e.attachments {
13431356
if att.placeholder == placeholder {
1344-
return
1357+
return nil
13451358
}
13461359
}
13471360

@@ -1352,6 +1365,7 @@ func (e *editor) addFileAttachment(placeholder string) {
13521365
sizeBytes: int(info.Size()),
13531366
isTemp: false,
13541367
})
1368+
return nil
13551369
}
13561370

13571371
// collectAttachments returns structured attachments for all items referenced in
@@ -1451,6 +1465,28 @@ func (e *editor) SendContent() tea.Cmd {
14511465
}
14521466

14531467
func (e *editor) handlePaste(content string) bool {
1468+
// First, try to parse as file paths (drag-and-drop)
1469+
filePaths := ParsePastedFiles(content)
1470+
if len(filePaths) > 0 {
1471+
var attached int
1472+
for _, path := range filePaths {
1473+
if !IsSupportedFileType(path) {
1474+
break
1475+
}
1476+
if err := e.AttachFile(path); err != nil {
1477+
slog.Debug("paste path not attachable, treating as text", "path", path, "error", err)
1478+
break
1479+
}
1480+
attached++
1481+
}
1482+
if attached == len(filePaths) {
1483+
return true
1484+
}
1485+
// Not all files could be attached; undo partial attachments and fall through to text paste
1486+
e.removeLastNAttachments(attached)
1487+
}
1488+
1489+
// Not file paths, handle as text paste
14541490
// Count lines (newlines + 1 for content without trailing newline)
14551491
lines := strings.Count(content, "\n") + 1
14561492
if strings.HasSuffix(content, "\n") {
@@ -1477,6 +1513,21 @@ func (e *editor) handlePaste(content string) bool {
14771513
return true
14781514
}
14791515

1516+
// removeLastNAttachments removes the last n non-temp attachments.
1517+
// Used to roll back partial file-drop attachments when not all files in a paste are valid.
1518+
func (e *editor) removeLastNAttachments(n int) {
1519+
if n <= 0 {
1520+
return
1521+
}
1522+
removed := 0
1523+
for i := len(e.attachments) - 1; i >= 0 && removed < n; i-- {
1524+
if !e.attachments[i].isTemp {
1525+
e.attachments = append(e.attachments[:i], e.attachments[i+1:]...)
1526+
removed++
1527+
}
1528+
}
1529+
}
1530+
14801531
func (e *editor) updateAttachmentBanner() {
14811532
if e.banner == nil {
14821533
return

pkg/tui/components/editor/paste.go

Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
package editor
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"slices"
7+
"strings"
8+
)
9+
10+
// validateFilePath checks that a path is safe: no path traversal, no symlinks.
11+
func validateFilePath(path string) (os.FileInfo, error) {
12+
if strings.Contains(path, "..") {
13+
return nil, os.ErrPermission
14+
}
15+
16+
clean := filepath.Clean(path)
17+
18+
info, err := os.Lstat(clean)
19+
if err != nil {
20+
return nil, err
21+
}
22+
if info.Mode()&os.ModeSymlink != 0 {
23+
return nil, os.ErrPermission
24+
}
25+
return info, nil
26+
}
27+
28+
// Supported file extensions for drag-and-drop attachments
29+
var supportedFileExtensions = []string{
30+
// Images
31+
".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".svg",
32+
// PDFs
33+
".pdf",
34+
// Text files (future)
35+
// ".txt", ".md", ".json", ".yaml", ".yml", ".toml",
36+
}
37+
38+
// ParsePastedFiles attempts to parse pasted content as file paths.
39+
// It handles different terminal formats:
40+
// - Unix: space-separated with backslash escaping
41+
// - Windows Terminal: quote-wrapped paths
42+
// - Single file: just the path
43+
//
44+
// Returns nil if the content doesn't look like file paths.
45+
func ParsePastedFiles(s string) []string {
46+
s = strings.TrimSpace(s)
47+
if s == "" {
48+
return nil
49+
}
50+
51+
// NOTE: Rio terminal on Windows adds NULL chars for some reason.
52+
s = strings.ReplaceAll(s, "\x00", "")
53+
54+
// Try simple stat first - if all lines are valid files, use them
55+
if attemptStatAll(s) {
56+
return strings.Split(s, "\n")
57+
}
58+
59+
// Detect Windows Terminal format (quote-wrapped)
60+
if os.Getenv("WT_SESSION") != "" {
61+
return windowsTerminalParsePastedFiles(s)
62+
}
63+
64+
// Default to Unix format (space-separated with backslash escaping)
65+
return unixParsePastedFiles(s)
66+
}
67+
68+
// attemptStatAll tries to stat each line as a file path.
69+
// Returns true if ALL lines exist as regular files (not directories or symlinks).
70+
func attemptStatAll(s string) bool {
71+
lines := strings.Split(s, "\n")
72+
if len(lines) == 0 {
73+
return false
74+
}
75+
76+
for _, line := range lines {
77+
line = strings.TrimSpace(line)
78+
if line == "" {
79+
continue
80+
}
81+
info, err := validateFilePath(line)
82+
if err != nil || info.IsDir() {
83+
return false
84+
}
85+
}
86+
return true
87+
}
88+
89+
// windowsTerminalParsePastedFiles parses Windows Terminal format.
90+
// Windows Terminal wraps file paths in quotes: "C:\path\to\file.png"
91+
func windowsTerminalParsePastedFiles(s string) []string {
92+
if strings.TrimSpace(s) == "" {
93+
return nil
94+
}
95+
96+
var (
97+
paths []string
98+
current strings.Builder
99+
inQuotes = false
100+
)
101+
102+
for i := range len(s) {
103+
ch := s[i]
104+
105+
switch {
106+
case ch == '"':
107+
if inQuotes {
108+
// End of quoted section
109+
if current.Len() > 0 {
110+
paths = append(paths, current.String())
111+
current.Reset()
112+
}
113+
inQuotes = false
114+
} else {
115+
// Start of quoted section
116+
inQuotes = true
117+
}
118+
case inQuotes:
119+
current.WriteByte(ch)
120+
case ch != ' ' && ch != '\n' && ch != '\r':
121+
// Text outside quotes is not allowed
122+
return nil
123+
}
124+
}
125+
126+
// Add any remaining content if quotes were properly closed
127+
if current.Len() > 0 && !inQuotes {
128+
paths = append(paths, current.String())
129+
}
130+
131+
// If quotes were not closed, return nil (malformed input)
132+
if inQuotes {
133+
return nil
134+
}
135+
136+
return paths
137+
}
138+
139+
// unixParsePastedFiles parses Unix terminal format.
140+
// Unix terminals use space-separated paths with backslash escaping.
141+
// Example: /path/to/file1.png /path/to/my\ file\ with\ spaces.jpg
142+
func unixParsePastedFiles(s string) []string {
143+
if strings.TrimSpace(s) == "" {
144+
return nil
145+
}
146+
147+
var (
148+
paths []string
149+
current strings.Builder
150+
escaped = false
151+
)
152+
153+
for i := range len(s) {
154+
ch := s[i]
155+
156+
switch {
157+
case escaped:
158+
// After a backslash, add the character as-is (including space)
159+
current.WriteByte(ch)
160+
escaped = false
161+
case ch == '\\':
162+
if i == len(s)-1 {
163+
// Trailing backslash is malformed input; strip it
164+
break
165+
}
166+
escaped = true
167+
case ch == ' ' || ch == '\n' || ch == '\r':
168+
// Space/newline separates paths (unless escaped)
169+
if current.Len() > 0 {
170+
paths = append(paths, current.String())
171+
current.Reset()
172+
}
173+
default:
174+
current.WriteByte(ch)
175+
}
176+
}
177+
178+
// Handle trailing backslash if present
179+
if escaped {
180+
current.WriteByte('\\')
181+
}
182+
183+
// Add the last path if any
184+
if current.Len() > 0 {
185+
paths = append(paths, current.String())
186+
}
187+
188+
return paths
189+
}
190+
191+
// IsSupportedFileType checks if a file has a supported extension.
192+
func IsSupportedFileType(path string) bool {
193+
ext := strings.ToLower(filepath.Ext(path))
194+
return slices.Contains(supportedFileExtensions, ext)
195+
}
196+
197+
// GetFileType returns a human-readable file type for display.
198+
func GetFileType(path string) string {
199+
ext := strings.ToLower(filepath.Ext(path))
200+
switch ext {
201+
case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".svg":
202+
return "image"
203+
case ".pdf":
204+
return "pdf"
205+
case ".txt", ".md":
206+
return "text"
207+
case ".json", ".yaml", ".yml", ".toml":
208+
return "config"
209+
default:
210+
return "file"
211+
}
212+
}
213+
214+
// GetFileIcon returns an emoji icon for a file type.
215+
func GetFileIcon(fileType string) string {
216+
switch fileType {
217+
case "image":
218+
return "🖼️"
219+
case "pdf":
220+
return "📄"
221+
case "text":
222+
return "📝"
223+
case "config":
224+
return "⚙️"
225+
default:
226+
return "📎"
227+
}
228+
}

0 commit comments

Comments
 (0)