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>
90 lines
2.4 KiB
Go
90 lines
2.4 KiB
Go
package web
|
|
|
|
import (
|
|
"archive/zip"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// CodeArchive turns one reply into a zip of the files it contains.
|
|
//
|
|
// Server-side because the interesting part is path handling, and a filename
|
|
// chosen by a language model is exactly the kind of input that wants
|
|
// archive/zip and a tested sanitiser rather than string concatenation in a
|
|
// browser. Nothing is stored: the reply arrives, the zip goes back, and
|
|
// neither is kept.
|
|
func CodeArchive(maxBody int64) func(w http.ResponseWriter, r *http.Request) {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
noStore(w)
|
|
|
|
var in struct {
|
|
Content string `json:"content"`
|
|
Name string `json:"name"`
|
|
}
|
|
body, err := io.ReadAll(io.LimitReader(r.Body, maxBody))
|
|
if err != nil || json.Unmarshal(body, &in) != nil {
|
|
http.Error(w, "could not read the request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
files := ExtractCode(in.Content)
|
|
if len(files) == 0 {
|
|
http.Error(w, "there is no code in that reply to download", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Headers before the first write: once a byte of zip is out, an error
|
|
// can only be delivered as a corrupt download.
|
|
w.Header().Set("Content-Type", "application/zip")
|
|
w.Header().Set("Content-Disposition",
|
|
fmt.Sprintf("attachment; filename=%q", archiveName(in.Name)))
|
|
// The archive is assembled from model output; nothing should sniff it
|
|
// as something else.
|
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
|
|
|
zw := zip.NewWriter(w)
|
|
for _, f := range files {
|
|
hdr := &zip.FileHeader{Name: f.Path, Method: zip.Deflate, Modified: time.Now()}
|
|
hdr.SetMode(0o644)
|
|
out, err := zw.CreateHeader(hdr)
|
|
if err != nil {
|
|
// Half a zip is already on the wire; closing is the only
|
|
// honest end, and the browser will report a broken download.
|
|
_ = zw.Close()
|
|
return
|
|
}
|
|
if _, err := io.WriteString(out, f.Body); err != nil {
|
|
_ = zw.Close()
|
|
return
|
|
}
|
|
}
|
|
_ = zw.Close()
|
|
}
|
|
}
|
|
|
|
// archiveName keeps the download recognisable without trusting what it is given.
|
|
func archiveName(name string) string {
|
|
name = strings.TrimSpace(name)
|
|
keep := strings.Map(func(r rune) rune {
|
|
switch {
|
|
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
|
|
return r
|
|
case r == '-', r == '_':
|
|
return r
|
|
default:
|
|
return -1
|
|
}
|
|
}, name)
|
|
if keep == "" {
|
|
keep = "code"
|
|
}
|
|
if len(keep) > 40 {
|
|
keep = keep[:40]
|
|
}
|
|
return keep + ".zip"
|
|
}
|