CI / test (push) Successful in 1m25s
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>
138 lines
4.1 KiB
Go
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)
|
|
}
|
|
}
|