Files
llmgw/internal/web/codeblocks_test.go
T
Ilker ManapandClaude Opus 5 45d0dee470 Let a reply full of code be downloaded as a zip
Asking a coding model for anything real produces a small tree, and the only
way to get it out of the page was to select each block and paste it
somewhere. A reply containing fenced code now offers a zip, with the
directory structure the model described.

Server-side, because the interesting part is the filenames. They are written
by a language model and they go into an archive that somebody extracts with
a tool that trusts archives, so "../../.ssh/authorized_keys" and
"/etc/cron.d/x" are inputs to expect rather than hope against. archive/zip
and a sanitiser with tests beat string handling in a browser. An absolute
path is made relative — it cannot escape once the leading separator is gone,
and refusing a file over a leading slash throws away work. Anything that
still escapes after cleaning is rejected rather than repaired, because
silently turning ../../evil.go into evil.go puts a file somewhere the reader
did not expect; it gets a generated name instead.

Naming follows what models actually do: on the fence (```go:cmd/main.go,
```main.go, ```go main.go, title="x"), or on the line above it as a heading
or comment. Only the two lines immediately above, so a sentence mentioning
main.go is not read as a filename. A block nobody named still comes back
under a generated name, because losing code because it was unlabelled is
worse than naming it badly, and two blocks claiming one path both survive —
models show a file and then show it again with one line changed.

Nothing is stored: the reply arrives, the zip goes back, neither is kept.
The endpoint sits behind the same session guard and CSRF check as
/chat/send.

Two of these tests asserted the wrong thing when first written — that an
absolute path must be refused, and that hyphens are stripped from the
archive name. Both were corrected to what the code should do rather than
the code changed to match them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 21:06:07 +02:00

138 lines
4.1 KiB
Go

package web
import (
"strings"
"testing"
)
func TestSafePathRefusesEverythingThatEscapes(t *testing.T) {
// The name comes from a language model and goes into an archive somebody
// extracts. These are things to expect, not things to hope against.
for _, bad := range []string{
"../secrets.txt",
"../../.ssh/authorized_keys",
"a/../../b",
"..",
".",
"",
" ",
"a/./../../b",
"foo\x00.go",
"foo\n../bar",
strings.Repeat("a", 200) + ".go",
strings.Repeat("a/", 20) + "x.go",
} {
if got := safePath(bad); got != "" {
t.Errorf("safePath(%q) = %q; it must be refused", bad, got)
}
}
}
func TestSafePathKeepsOrdinaryNames(t *testing.T) {
for in, want := range map[string]string{
"main.go": "main.go",
"cmd/server/main.go": "cmd/server/main.go",
"./pkg/x.go": "pkg/x.go",
"/src/app.py": "src/app.py",
`src\app.py`: "src/app.py",
"a/b/../c.txt": "a/c.txt",
"Dockerfile": "Dockerfile",
"deploy/k8s/deploy.yml": "deploy/k8s/deploy.yml",
} {
if got := safePath(in); got != want {
t.Errorf("safePath(%q) = %q, want %q", in, got, want)
}
}
}
func TestExtractNamesFilesTheWayModelsWriteThem(t *testing.T) {
reply := "Here you go.\n\n" +
"```go:cmd/api/main.go\npackage main\n```\n\n" +
"**internal/store/store.go**\n" +
"```go\npackage store\n```\n\n" +
"```python scripts/run.py\nprint(1)\n```\n\n" +
"```Dockerfile\nFROM scratch\n```\n"
files := ExtractCode(reply)
if len(files) != 4 {
t.Fatalf("got %d files: %+v", len(files), files)
}
want := []string{"cmd/api/main.go", "internal/store/store.go", "scripts/run.py", "Dockerfile"}
for i, w := range want {
if files[i].Path != w {
t.Errorf("file %d: path %q, want %q", i, files[i].Path, w)
}
}
if !strings.Contains(files[0].Body, "package main") {
t.Errorf("body lost: %q", files[0].Body)
}
}
func TestAnUnnamedBlockIsStillReturned(t *testing.T) {
// Losing someone's code because the model did not label it is worse than
// naming it badly.
files := ExtractCode("```go\npackage main\n```\n```\nplain text\n```\n")
if len(files) != 2 {
t.Fatalf("got %d files", len(files))
}
if files[0].Path != "snippet-1.go" || files[1].Path != "snippet-2.txt" {
t.Errorf("generated names wrong: %q, %q", files[0].Path, files[1].Path)
}
}
func TestAnEscapingNameFallsBackRatherThanBeingRepaired(t *testing.T) {
// Silently rewriting ../../x.go into x.go would put a file somewhere the
// reader did not expect. It gets a generated name instead.
files := ExtractCode("```go:../../evil.go\npackage main\n```\n")
if len(files) != 1 {
t.Fatalf("got %d files", len(files))
}
if strings.Contains(files[0].Path, "..") {
t.Fatalf("an escaping path survived: %q", files[0].Path)
}
if files[0].Path != "snippet-1.go" {
t.Errorf("path = %q, want the generated name", files[0].Path)
}
}
func TestTwoBlocksClaimingOnePathBothSurvive(t *testing.T) {
// Models show a file, then show it again with one line changed.
files := ExtractCode("```go:main.go\nv1\n```\n```go:main.go\nv2\n```\n")
if len(files) != 2 {
t.Fatalf("got %d files", len(files))
}
if files[0].Path == files[1].Path {
t.Fatalf("one overwrote the other: both are %q", files[0].Path)
}
if files[1].Path != "main-2.go" {
t.Errorf("second path = %q, want main-2.go", files[1].Path)
}
}
func TestProseMentioningAFileIsNotAFilename(t *testing.T) {
reply := "You should now edit main.go and rebuild.\n```go\npackage main\n```\n"
files := ExtractCode(reply)
if len(files) != 1 {
t.Fatalf("got %d files", len(files))
}
if files[0].Path != "snippet-1.go" {
t.Errorf("a sentence was read as a filename: %q", files[0].Path)
}
}
func TestATextReplyWithNoCodeYieldsNothing(t *testing.T) {
if files := ExtractCode("No code here, just words about `main.go`.\n"); len(files) != 0 {
t.Errorf("got %d files from prose: %+v", len(files), files)
}
}
func TestTheNumberOfFilesIsBounded(t *testing.T) {
var b strings.Builder
for range maxCodeFiles + 20 {
b.WriteString("```go\nx\n```\n")
}
if got := len(ExtractCode(b.String())); got > maxCodeFiles {
t.Errorf("returned %d files, over the %d cap", got, maxCodeFiles)
}
}