返回 DeepSeek-Reasonix
chat_file_reference_test.go
根目录 / desktop / chat_file_reference_test.go
1 package main
2
3 import (
4 "context"
5 "os"
6 "path/filepath"
7 "runtime"
8 "slices"
9 "strings"
10 "testing"
11 "time"
12
13 "reasonix/internal/control"
14 )
15
16 func newChatReferenceApp(t *testing.T, root string) *App {
17 t.Helper()
18 isolateDesktopUserDirs(t)
19 app := NewApp()
20 t.Cleanup(func() { app.shutdown(context.Background()) })
21 tab := &WorkspaceTab{ID: "refs", WorkspaceRoot: root}
22 app.tabs[tab.ID] = tab
23 app.activeTabID = tab.ID
24 return app
25 }
26
27 func writeChatReferenceFile(t *testing.T, root, rel, body string) string {
28 t.Helper()
29 path := filepath.Join(root, filepath.FromSlash(rel))
30 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
31 t.Fatal(err)
32 }
33 if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
34 t.Fatal(err)
35 }
36 return path
37 }
38
39 func resolveOne(t *testing.T, app *App, candidate string) ChatFileReference {
40 t.Helper()
41 result := app.ResolveChatFileReferencesForTab("refs", "turn-1", []ChatFileReferenceRequest{{Key: "k", Path: candidate}})
42 if result.TurnKey != "turn-1" {
43 t.Fatalf("turn key not echoed: %q", result.TurnKey)
44 }
45 if len(result.References) != 1 {
46 t.Fatalf("resolve(%q) returned %d references, want 1", candidate, len(result.References))
47 }
48 return result.References[0]
49 }
50
51 func TestChatFileReferenceResolvesWorkspaceRelativeAndAbsolute(t *testing.T) {
52 root := t.TempDir()
53 app := newChatReferenceApp(t, root)
54 writeChatReferenceFile(t, root, "out/图 (1).svg", `<svg xmlns="http://www.w3.org/2000/svg"/>`)
55
56 for _, candidate := range []string{
57 "out/图 (1).svg",
58 "./out/图 (1).svg",
59 filepath.ToSlash(filepath.Join(root, "out/图 (1).svg")),
60 } {
61 got := resolveOne(t, app, candidate)
62 if got.Status != "resolved" {
63 t.Fatalf("resolve(%q) = %s/%s, want resolved", candidate, got.Status, got.Reason)
64 }
65 if got.DisplayPath != "out/图 (1).svg" {
66 t.Fatalf("resolve(%q) display path = %q, want the workspace-relative spelling", candidate, got.DisplayPath)
67 }
68 if got.Kind != "image" {
69 t.Fatalf("resolve(%q) kind = %q, want image", candidate, got.Kind)
70 }
71 }
72 }
73
74 func TestChatFileReferencePreservesRawFilenameCharacters(t *testing.T) {
75 root := t.TempDir()
76 app := newChatReferenceApp(t, root)
77 writeChatReferenceFile(t, root, "out/raw%20name.txt", "percent")
78 writeChatReferenceFile(t, root, "out/raw name.txt", "space")
79 if got := resolveOne(t, app, "out/raw%20name.txt"); got.Status != "resolved" || got.DisplayPath != "out/raw%20name.txt" {
80 t.Fatalf("literal percent path = %+v", got)
81 }
82 if preview := app.ReadReferenceFileForTab("refs", "out/raw%20name.txt"); preview.Err != "" || preview.Body != "percent" {
83 t.Fatalf("literal percent content = %+v", preview)
84 }
85 percentURL := localFileHref(filepath.Join(root, "out", "raw%20name.txt"))
86 if got := resolveOne(t, app, percentURL); got.Status != "resolved" || got.DisplayPath != "out/raw%20name.txt" {
87 t.Fatalf("encoded file URL = %+v", got)
88 }
89 if preview := app.ReadReferenceFileForTab("refs", percentURL); preview.Err != "" || preview.Body != "percent" {
90 t.Fatalf("encoded file URL content = %+v", preview)
91 }
92 if runtime.GOOS == "windows" {
93 return
94 }
95 writeChatReferenceFile(t, root, "out/raw?query#fragment.txt", "punctuation")
96 if got := resolveOne(t, app, "out/raw?query#fragment.txt"); got.Status != "resolved" || got.DisplayPath != "out/raw?query#fragment.txt" {
97 t.Fatalf("literal query/fragment path = %+v", got)
98 }
99 }
100
101 func TestLocalPathSourceDecodesURLPathExactlyOnce(t *testing.T) {
102 tests := []struct {
103 source string
104 want string
105 }{
106 {"folder/raw%2520name.txt", filepath.FromSlash("folder/raw%20name.txt")},
107 {"folder/raw%20name.txt", filepath.FromSlash("folder/raw name.txt")},
108 {"folder/100%25.txt", filepath.FromSlash("folder/100%.txt")},
109 {"folder/%E4%B8%AD%E6%96%87%23.txt", filepath.FromSlash("folder/中文#.txt")},
110 }
111 for _, tt := range tests {
112 got, err := localPathSource(tt.source)
113 if err != nil || got != tt.want {
114 t.Fatalf("localPathSource(%q) = %q, %v; want %q", tt.source, got, err, tt.want)
115 }
116 }
117 for _, source := range []string{"folder/%00.txt", "file:///tmp/%00.txt"} {
118 if _, err := localPathSource(source); err == nil {
119 t.Fatalf("localPathSource(%q) accepted NUL", source)
120 }
121 }
122 }
123
124 func TestChatFileReferenceOffersSourceForTextMedia(t *testing.T) {
125 root := t.TempDir()
126 app := newChatReferenceApp(t, root)
127 writeChatReferenceFile(t, root, "out/diagram.svg", `<svg xmlns="http://www.w3.org/2000/svg"/>`)
128 writeChatReferenceFile(t, root, "out/shot.png", "not really a png")
129 writeChatReferenceFile(t, root, "out/notes.md", "# notes")
130
131 svg := resolveOne(t, app, "out/diagram.svg")
132 if !slices.Contains(svg.Actions, "source") {
133 t.Fatalf("SVG is a text format and must offer the source view: %v", svg.Actions)
134 }
135 png := resolveOne(t, app, "out/shot.png")
136 if slices.Contains(png.Actions, "source") {
137 t.Fatalf("a raster image must not offer the source view: %v", png.Actions)
138 }
139 notes := resolveOne(t, app, "out/notes.md")
140 if !slices.Contains(notes.Actions, "source") || notes.Kind != "" {
141 t.Fatalf("plain text resolve = kind %q actions %v", notes.Kind, notes.Actions)
142 }
143 for _, action := range []string{"preview", "reveal-tree", "copy-path", "save-copy", "open-native", "reveal-native"} {
144 if !slices.Contains(notes.Actions, action) {
145 t.Fatalf("plain text is missing %q: %v", action, notes.Actions)
146 }
147 }
148 }
149
150 func TestChatFileReferenceRejectsEscapeAndNonRegularFiles(t *testing.T) {
151 root := t.TempDir()
152 outside := t.TempDir()
153 app := newChatReferenceApp(t, root)
154 writeChatReferenceFile(t, outside, "secret.txt", "secret")
155 writeChatReferenceFile(t, root, "out/real.txt", "real")
156 if err := os.MkdirAll(filepath.Join(root, "out", "dir"), 0o755); err != nil {
157 t.Fatal(err)
158 }
159 if err := os.Symlink(filepath.Join(root, "out", "real.txt"), filepath.Join(root, "out", "link.txt")); err != nil {
160 t.Skipf("symlinks unavailable: %v", err)
161 }
162 // A link that leaves the workspace is refused for the escape, not for being
163 // a link, so the reason stays the one the reader can act on.
164 if err := os.Symlink(filepath.Join(outside, "secret.txt"), filepath.Join(root, "out", "escape-link.txt")); err != nil {
165 t.Skipf("symlinks unavailable: %v", err)
166 }
167
168 for _, test := range []struct {
169 name string
170 candidate string
171 status string
172 }{
173 {"parent escape", "../secret.txt", "unavailable"},
174 {"absolute escape", filepath.ToSlash(filepath.Join(outside, "secret.txt")), "unavailable"},
175 {"symlink out of tree", "out/escape-link.txt", "unavailable"},
176 {"directory", "out/dir", "unsupported"},
177 {"missing file", "out/missing.txt", "unavailable"},
178 {"empty", " ", "unsupported"},
179 {"nul byte", "out/\x00.txt", "unsupported"},
180 } {
181 t.Run(test.name, func(t *testing.T) {
182 got := resolveOne(t, app, test.candidate)
183 if got.Status != test.status {
184 t.Fatalf("resolve(%q) = %s/%s, want %s", test.candidate, got.Status, got.Reason, test.status)
185 }
186 if got.DisplayPath != "" || len(got.Actions) != 0 {
187 t.Fatalf("rejected candidate exposed a target: %+v", got)
188 }
189 })
190 }
191 }
192
193 // A link that stays inside the workspace resolves to its target, and the
194 // reported path is the target: the panel must name the file it will show.
195 func TestChatFileReferenceFollowsInTreeSymlinkToItsTarget(t *testing.T) {
196 root := t.TempDir()
197 app := newChatReferenceApp(t, root)
198 writeChatReferenceFile(t, root, "out/real.txt", "real")
199 if err := os.Symlink(filepath.Join(root, "out", "real.txt"), filepath.Join(root, "out", "link.txt")); err != nil {
200 t.Skipf("symlinks unavailable: %v", err)
201 }
202 got := resolveOne(t, app, "out/link.txt")
203 if got.Status != "resolved" || got.DisplayPath != "out/real.txt" {
204 t.Fatalf("in-tree symlink resolve = %+v, want the target path", got)
205 }
206 }
207
208 func TestChatFileReferenceRejectsSymlinkedDirectoryEscape(t *testing.T) {
209 root := t.TempDir()
210 outside := t.TempDir()
211 app := newChatReferenceApp(t, root)
212 writeChatReferenceFile(t, outside, "secret.txt", "secret")
213 if err := os.Symlink(outside, filepath.Join(root, "escape")); err != nil {
214 t.Skipf("symlinks unavailable: %v", err)
215 }
216 got := resolveOne(t, app, "escape/secret.txt")
217 if got.Status != "unavailable" || got.Reason != "outside-workspace" {
218 t.Fatalf("a symlinked directory escaped the workspace: %+v", got)
219 }
220 }
221
222 func TestChatFileReferenceAcceptsAuthorizedExternalFolder(t *testing.T) {
223 isolateDesktopUserDirs(t)
224 root := t.TempDir()
225 external := t.TempDir()
226 writeChatReferenceFile(t, external, "shared/dropped.svg", `<svg xmlns="http://www.w3.org/2000/svg"/>`)
227
228 ctrl := control.New(control.Options{SessionDir: t.TempDir(), SessionPath: filepath.Join(t.TempDir(), "s.jsonl"), Label: "refs", WorkspaceRoot: root})
229 if _, _, err := ctrl.RegisterExternalFolderRef(external); err != nil {
230 t.Fatalf("RegisterExternalFolderRef: %v", err)
231 }
232 app := NewApp()
233 t.Cleanup(func() { app.shutdown(context.Background()) })
234 tab := &WorkspaceTab{ID: "refs", WorkspaceRoot: root, Ctrl: ctrl}
235 app.tabs[tab.ID] = tab
236
237 got := resolveOne(t, app, filepath.ToSlash(filepath.Join(external, "shared/dropped.svg")))
238 if got.Status != "resolved" {
239 t.Fatalf("an authorized external file was rejected: %+v", got)
240 }
241 realExternal, err := filepath.EvalSymlinks(external)
242 if err != nil {
243 t.Fatal(err)
244 }
245 if got.DisplayPath != filepath.ToSlash(filepath.Join(realExternal, "shared/dropped.svg")) {
246 t.Fatalf("external display path = %q", got.DisplayPath)
247 }
248 // The same file becomes unreachable once the session stops authorizing it.
249 other := NewApp()
250 t.Cleanup(func() { other.shutdown(context.Background()) })
251 other.tabs[tab.ID] = &WorkspaceTab{ID: "refs", WorkspaceRoot: root}
252 if again := resolveOne(t, other, filepath.ToSlash(filepath.Join(external, "shared/dropped.svg"))); again.Status != "unavailable" {
253 t.Fatalf("resolution outlived its session authorization: %+v", again)
254 }
255 }
256
257 func TestChatFileReferenceBatchLimitsAndShape(t *testing.T) {
258 root := t.TempDir()
259 app := newChatReferenceApp(t, root)
260 writeChatReferenceFile(t, root, "out/a.txt", "a")
261
262 empty := app.ResolveChatFileReferencesForTab("refs", "t", nil)
263 if empty.References == nil || len(empty.References) != 0 {
264 t.Fatalf("empty batch must stay a non-nil empty array: %#v", empty.References)
265 }
266
267 long := strings.Repeat("a", chatFileReferenceMaxChars+1)
268 got := resolveOne(t, app, long)
269 if got.Status != "unsupported" || got.Reason != "too-long" {
270 t.Fatalf("over-long candidate = %s/%s, want unsupported/too-long", got.Status, got.Reason)
271 }
272
273 candidates := make([]ChatFileReferenceRequest, 0, chatFileReferenceBatchLimit+10)
274 for range chatFileReferenceBatchLimit + 10 {
275 candidates = append(candidates, ChatFileReferenceRequest{Key: "k", Path: "out/a.txt"})
276 }
277 result := app.ResolveChatFileReferencesForTab("refs", "t", candidates)
278 if len(result.References) != chatFileReferenceBatchLimit {
279 t.Fatalf("batch of %d returned %d items, want %d", len(candidates), len(result.References), chatFileReferenceBatchLimit)
280 }
281 }
282
283 func TestChatFileReferenceActionsRevalidateOnEveryCall(t *testing.T) {
284 root := t.TempDir()
285 app := newChatReferenceApp(t, root)
286 path := writeChatReferenceFile(t, root, "out/report.md", "body")
287
288 if got := resolveOne(t, app, "out/report.md"); got.Status != "resolved" {
289 t.Fatalf("setup: %+v", got)
290 }
291 if preview := app.ReadReferenceFileForTab("refs", "out/report.md"); preview.Err != "" || preview.Body != "body" {
292 t.Fatalf("read = %+v", preview)
293 }
294 if source := app.ReadReferenceFileSourceForTab("refs", "out/report.md"); source.Err != "" || source.Body != "body" {
295 t.Fatalf("source read = %+v", source)
296 }
297 realPath, err := filepath.EvalSymlinks(path)
298 if err != nil {
299 t.Fatal(err)
300 }
301 if resolved, err := app.ResolveReferencePathForTab("refs", "out/report.md"); err != nil || resolved != realPath {
302 t.Fatalf("resolve path = %q err %v, want %q", resolved, err, realPath)
303 }
304
305 if err := os.Remove(path); err != nil {
306 t.Fatal(err)
307 }
308 if preview := app.ReadReferenceFileForTab("refs", "out/report.md"); preview.Err == "" {
309 t.Fatal("a deleted reference still read successfully")
310 }
311 if _, err := app.ResolveReferencePathForTab("refs", "out/report.md"); err == nil {
312 t.Fatal("a deleted reference still resolved a path")
313 }
314 if _, err := app.SaveReferencePathAsForTab("refs", "out/report.md"); err == nil {
315 t.Fatal("a deleted reference still reached the save dialog")
316 }
317 }
318
319 func TestChatFileReferenceHonorsCurrentReadPolicy(t *testing.T) {
320 root := t.TempDir()
321 app := newChatReferenceApp(t, root)
322 writeChatReferenceFile(t, root, "secret/token.txt", "secret")
323 if err := os.WriteFile(filepath.Join(root, "reasonix.toml"), []byte("[sandbox]\nforbid_read = [\"secret\"]\n"), 0o600); err != nil {
324 t.Fatal(err)
325 }
326 got := resolveOne(t, app, "secret/token.txt")
327 if got.Status != "unavailable" || got.Reason != "blocked" {
328 t.Fatalf("forbid_read did not block an answer reference: %+v", got)
329 }
330 }
331
332 func TestChatFileReferenceRejectsUnknownSession(t *testing.T) {
333 isolateDesktopUserDirs(t)
334 app := NewApp()
335 result := app.ResolveChatFileReferencesForTab("missing-tab", "t", []ChatFileReferenceRequest{{Key: "k", Path: "/etc/passwd"}})
336 if len(result.References) != 1 || result.References[0].Status != "unavailable" || result.References[0].Reason != "unknown-session" {
337 t.Fatalf("unknown session resolved a reference: %+v", result.References)
338 }
339 }
340
341 // TestChatFileReferenceAcceptsWindowsSpellingsOnWindows keeps the drive/UNC
342 // matrix honest on the platform that owns those rules; every other host must
343 // refuse them rather than guess.
344 func TestChatFileReferenceAcceptsWindowsSpellingsOnWindows(t *testing.T) {
345 if runtime.GOOS != "windows" {
346 t.Skip("drive and UNC paths are only meaningful on the host that owns them")
347 }
348 root := t.TempDir()
349 app := newChatReferenceApp(t, root)
350 writeChatReferenceFile(t, root, "out/drive.svg", `<svg xmlns="http://www.w3.org/2000/svg"/>`)
351 for _, candidate := range []string{
352 filepath.Join(root, "out", "drive.svg"),
353 strings.ToLower(filepath.Join(root, "out", "drive.svg")),
354 "file:///" + strings.ReplaceAll(filepath.Join(root, "out", "drive.svg"), `\`, "/"),
355 } {
356 got := resolveOne(t, app, candidate)
357 if got.Status != "resolved" {
358 t.Fatalf("resolve(%q) = %s/%s, want resolved", candidate, got.Status, got.Reason)
359 }
360 }
361 }
362
363 func TestChatFileReferenceRejectsUnsupportedSchemes(t *testing.T) {
364 root := t.TempDir()
365 app := newChatReferenceApp(t, root)
366 for _, candidate := range []string{
367 "http://example.com/x.svg",
368 "https://example.com/x.svg",
369 "data:image/svg+xml;base64,PHN2Zy8+",
370 "file://server/share/x.svg",
371 } {
372 got := resolveOne(t, app, candidate)
373 if got.Status != "unsupported" && got.Status != "unavailable" {
374 t.Fatalf("resolve(%q) = %s, want a refusal", candidate, got.Status)
375 }
376 }
377 }
378
379 func TestSanitizeMarkdownSVGStripsActiveContent(t *testing.T) {
380 app := NewApp()
381 view := app.SanitizeMarkdownSVG(`<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10">
382 <defs><linearGradient id="g"><stop offset="0" stop-color="#f00"/></linearGradient></defs>
383 <script>alert(1)</script>
384 <rect width="10" height="10" fill="url(#g)" onload="alert(2)"/>
385 <text x="1" y="1">hello</text>
386 <foreignObject><body xmlns="http://www.w3.org/1999/xhtml">x</body></foreignObject>
387 <image href="https://example.com/x.png"/>
388 </svg>`)
389 if !view.OK {
390 t.Fatalf("a valid SVG was refused: %+v", view)
391 }
392 for _, forbidden := range []string{"script", "onload", "foreignObject", "example.com", "javascript:"} {
393 if strings.Contains(view.SVG, forbidden) {
394 t.Fatalf("sanitized SVG kept %q: %s", forbidden, view.SVG)
395 }
396 }
397 for _, kept := range []string{"linearGradient", "url(#g)", "<text", "hello"} {
398 if !strings.Contains(view.SVG, kept) {
399 t.Fatalf("sanitized SVG dropped %q: %s", kept, view.SVG)
400 }
401 }
402 }
403
404 func TestSanitizeMarkdownSVGEnforcesPreviewLimits(t *testing.T) {
405 app := NewApp()
406 // A model often omits xmlns; that stays valid. A different root or two
407 // roots must not preview as one image.
408 if view := app.SanitizeMarkdownSVG("<svg><rect/></svg>"); !view.OK {
409 t.Fatalf("an SVG without a namespace was refused: %+v", view)
410 }
411 for _, body := range []string{
412 `<html><body>x</body></html>`,
413 `<svg xmlns="http://www.w3.org/2000/svg"><rect/></svg><svg xmlns="http://www.w3.org/2000/svg"><rect/></svg>`,
414 `<svg xmlns="http://www.w3.org/2000/svg"><g></svg>`,
415 `not markup at all`,
416 } {
417 if view := app.SanitizeMarkdownSVG(body); view.OK {
418 t.Fatalf("accepted invalid input %q: %+v", body, view)
419 }
420 }
421
422 huge := `<svg xmlns="http://www.w3.org/2000/svg">` + strings.Repeat("<rect/>", markdownSVGPreviewMaxElements+1) + `</svg>`
423 if view := app.SanitizeMarkdownSVG(huge); view.OK || view.Reason != "invalid" {
424 t.Fatalf("an over-complex SVG was accepted: %+v", view)
425 }
426
427 deep := `<svg xmlns="http://www.w3.org/2000/svg">` + strings.Repeat("<g>", markdownSVGPreviewMaxDepth+1) + strings.Repeat("</g>", markdownSVGPreviewMaxDepth+1) + `</svg>`
428 if view := app.SanitizeMarkdownSVG(deep); view.OK {
429 t.Fatal("an over-nested SVG was accepted")
430 }
431
432 oversized := `<svg xmlns="http://www.w3.org/2000/svg">` + strings.Repeat(" ", markdownSVGPreviewMaxBytes) + `</svg>`
433 if view := app.SanitizeMarkdownSVG(oversized); view.OK || view.Reason != "too-large" {
434 t.Fatalf("an oversized SVG was accepted: %+v", view)
435 }
436 }
437
438 // A sanitizer that ran slowly would stall the transcript; this only guards the
439 // pathological shape the element ceiling exists for.
440 func TestSanitizeMarkdownSVGStaysBounded(t *testing.T) {
441 app := NewApp()
442 body := `<svg xmlns="http://www.w3.org/2000/svg">` + strings.Repeat(`<rect width="1" height="1"/>`, 5000) + `</svg>`
443 start := time.Now()
444 if view := app.SanitizeMarkdownSVG(body); !view.OK {
445 t.Fatalf("a 5000-element SVG was refused: %+v", view)
446 }
447 if elapsed := time.Since(start); elapsed > 5*time.Second {
448 t.Fatalf("sanitizing 5000 elements took %s", elapsed)
449 }
450 }
451
451 lines GO