| 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 |