aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore1
-rw-r--r--README62
-rw-r--r--go.mod3
-rw-r--r--main.go453
-rw-r--r--main_test.go246
5 files changed, 765 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..1c89afc
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+/xf
diff --git a/README b/README
new file mode 100644
index 0000000..cdb36a1
--- /dev/null
+++ b/README
@@ -0,0 +1,62 @@
+xf
+==
+xf (xfer) is a single-file HTTP file transfer server. Every directory under
+the served root renders as one web page that lists its entries with download
+links and provides a form to upload one or more files into that directory.
+
+
+Build
+-----
+Build a binary with the Go toolchain:
+ go build -o xf .
+Everything, including the web page, is embedded in main.go. There are no
+third-party dependencies.
+
+
+Run
+---
+Serve the current directory on loopback port 8080:
+ ./xf
+Serve a different directory or bind a different address:
+ ./xf -dir /srv/files -addr 192.0.2.10:9000
+Open the printed address in a browser. Uploads are streamed to a temporary
+file in the target directory. All files in a request are staged before any are
+renamed into place. Existing regular files are overwritten atomically and keep
+their mode; new files are created mode 0600.
+
+
+Options
+-------
+ -addr listen address (default "127.0.0.1:8080")
+ -dir directory to serve (default ".")
+ -max-upload maximum bytes in one upload request (default 1073741824)
+
+
+Debug
+-----
+The access log goes to standard output, one line per request:
+ 2026/07/02 10:14:38 get "/hello.txt" -> 200 (6 bytes) from 127.0.0.1
+ 2026/07/02 10:14:39 upload "/sub/up.txt" (9 bytes) from 127.0.0.1
+Startup and error diagnostics go to standard error. To exercise the server
+without a browser:
+ curl -F files=@somefile http://127.0.0.1:8080/
+ curl -O http://127.0.0.1:8080/somefile
+
+
+Security
+--------
+xf has no authentication. It serves every file under the directory, including
+hidden files, to anyone who can reach the address, and it accepts uploads from
+anyone. The default listener is loopback; binding another address deliberately
+exposes the server to that network.
+
+Filesystem access is confined to the served root. Relative symbolic links may
+point elsewhere inside the root, but absolute links and links that escape it
+are rejected. Upload requests are limited to 1 GiB by default and at most 1000
+multipart parts. The server does not limit upload duration or concurrent
+requests. Run it only on systems and networks you trust.
+
+An interrupted process can leave a mode-0600 .xf-upload-* staging file.
+These reserved names are neither listed nor served and can be removed when no
+xf process is using the directory. A rare rename failure while committing a
+multi-file request can leave an initial subset committed.
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..0b37efa
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,3 @@
+module xf
+
+go 1.25
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..3fc9686
--- /dev/null
+++ b/main.go
@@ -0,0 +1,453 @@
+// Command xf (xfer) is a single-file HTTP file transfer server. Every
+// directory under the served root renders as one embedded web page that lists
+// its entries and accepts uploads into that directory; every file is served
+// for download.
+//
+// Usage:
+//
+// xf [-addr address] [-dir directory] [-max-upload bytes]
+package main
+
+import (
+ "crypto/rand"
+ "encoding/hex"
+ "errors"
+ "flag"
+ "fmt"
+ "html/template"
+ "io"
+ "log"
+ "net"
+ "net/http"
+ "net/url"
+ "os"
+ "path"
+ "path/filepath"
+ "sort"
+ "strings"
+ "syscall"
+ "time"
+)
+
+const (
+ defaultMaxUpload = 1 << 30
+ maxUploadParts = 1000
+ tempPrefix = ".xf-upload-"
+
+ // readHeaderTimeout bounds a slow client without bounding the time it
+ // takes to stream a large upload body.
+ readHeaderTimeout = 10 * time.Second
+)
+
+var page = template.Must(template.New("index").Parse(indexHTML))
+
+// access logs downloads and uploads to standard output, kept separate from the
+// startup and error diagnostics the default logger writes to standard error.
+var access = log.New(os.Stdout, "", log.LstdFlags)
+
+type server struct {
+ root *os.Root
+ maxUpload int64
+}
+
+type stagedFile struct {
+ name string
+ temp string
+ size int64
+}
+
+// countedBody counts the bytes read from the request body. multipart can turn
+// a MaxBytesReader error into a parse error, so the count, not the error,
+// decides whether an upload was refused for its size.
+type countedBody struct {
+ io.ReadCloser
+ bytes int64
+}
+
+func (body *countedBody) Read(p []byte) (int, error) {
+ n, err := body.ReadCloser.Read(p)
+ body.bytes += int64(n)
+ return n, err
+}
+
+type entry struct {
+ Name string
+ Href string
+ Size string
+ IsDir bool
+}
+
+type pageData struct {
+ Dir string // URL path of the listed directory
+ Parent bool // whether to link to the parent directory
+ Entries []entry
+}
+
+func main() {
+ addr := flag.String("addr", "127.0.0.1:8080", "listen address")
+ dir := flag.String("dir", ".", "directory to serve")
+ maxUpload := flag.Int64("max-upload", defaultMaxUpload,
+ "maximum bytes in one upload request")
+ flag.Parse()
+ if *maxUpload <= 0 {
+ log.Fatal("xf: -max-upload must be greater than zero")
+ }
+
+ rootName, err := filepath.Abs(*dir)
+ if err != nil {
+ log.Fatalf("xf: %v", err)
+ }
+ root, err := os.OpenRoot(rootName)
+ if err != nil {
+ log.Fatalf("xf: %v", err)
+ }
+
+ s := &server{root: root, maxUpload: *maxUpload}
+
+ mux := http.NewServeMux()
+ mux.HandleFunc("GET /", s.handleGet)
+ mux.HandleFunc("POST /", s.handleUpload)
+
+ srv := &http.Server{
+ Addr: *addr,
+ Handler: mux,
+ ReadHeaderTimeout: readHeaderTimeout,
+ }
+
+ log.Printf("xf: serving %s on %s", rootName, *addr)
+ log.Fatalf("xf: %v", srv.ListenAndServe())
+}
+
+// resolve maps a request path to a path relative to the server root.
+func (s *server) resolve(urlPath string) string {
+ name := strings.TrimPrefix(path.Clean("/"+urlPath), "/")
+ if name == "" {
+ return "."
+ }
+ return filepath.FromSlash(name)
+}
+
+func (s *server) handleGet(w http.ResponseWriter, r *http.Request) {
+ rec := &recorder{ResponseWriter: w, status: http.StatusOK}
+ s.serve(rec, r)
+ access.Printf("get %q -> %d (%d bytes) from %s",
+ r.URL.Path, rec.status, rec.bytes, clientIP(r))
+}
+
+func (s *server) serve(w http.ResponseWriter, r *http.Request) {
+ name := s.resolve(r.URL.Path)
+ if isTempName(filepath.Base(name)) {
+ http.NotFound(w, r)
+ return
+ }
+ // O_NONBLOCK so that opening a FIFO cannot hang the handler; non-regular
+ // files are refused below.
+ f, err := s.root.OpenFile(name, os.O_RDONLY|syscall.O_NONBLOCK, 0)
+ switch {
+ case os.IsNotExist(err):
+ http.NotFound(w, r)
+ return
+ case os.IsPermission(err):
+ http.Error(w, "forbidden", http.StatusForbidden)
+ return
+ case err != nil:
+ // A path the root refuses, such as an escaping symlink or a
+ // component that is not a directory, is not a server fault.
+ log.Printf("xf: open %q: %v", r.URL.Path, err)
+ http.NotFound(w, r)
+ return
+ }
+ defer f.Close()
+ fi, err := f.Stat()
+ if err != nil {
+ log.Printf("xf: stat %q: %v", r.URL.Path, err)
+ http.Error(w, "internal server error", http.StatusInternalServerError)
+ return
+ }
+ if fi.IsDir() {
+ // Directory pages need a trailing slash so relative links work.
+ if !strings.HasSuffix(r.URL.Path, "/") {
+ http.Redirect(w, r, path.Clean(r.URL.Path)+"/", http.StatusMovedPermanently)
+ return
+ }
+ s.list(w, f, r.URL.Path)
+ return
+ }
+ if !fi.Mode().IsRegular() {
+ http.Error(w, "unsupported file type", http.StatusForbidden)
+ return
+ }
+ http.ServeContent(w, r, fi.Name(), fi.ModTime(), f)
+}
+
+func (s *server) list(w http.ResponseWriter, dir *os.File, urlPath string) {
+ dirents, err := dir.ReadDir(-1)
+ if err != nil {
+ log.Printf("xf: list %q: %v", urlPath, err)
+ http.Error(w, "internal server error", http.StatusInternalServerError)
+ return
+ }
+ sort.Slice(dirents, func(i, j int) bool {
+ if dirents[i].IsDir() != dirents[j].IsDir() {
+ return dirents[i].IsDir()
+ }
+ return dirents[i].Name() < dirents[j].Name()
+ })
+
+ entries := make([]entry, 0, len(dirents))
+ for _, de := range dirents {
+ if isTempName(de.Name()) {
+ continue
+ }
+ e := entry{
+ Name: de.Name(),
+ Href: (&url.URL{Path: de.Name()}).String(),
+ IsDir: de.IsDir(),
+ }
+ if de.IsDir() {
+ e.Href += "/"
+ e.Size = "-"
+ } else if fi, err := de.Info(); err == nil {
+ e.Size = humanSize(fi.Size())
+ } else {
+ e.Size = "?"
+ }
+ entries = append(entries, e)
+ }
+
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ data := pageData{Dir: urlPath, Parent: urlPath != "/", Entries: entries}
+ if err := page.Execute(w, data); err != nil {
+ log.Printf("xf: render: %v", err)
+ }
+}
+
+// handleUpload stages every file in the request before renaming any into
+// place, so a refused or interrupted upload leaves the directory untouched.
+func (s *server) handleUpload(w http.ResponseWriter, r *http.Request) {
+ dir, err := s.root.OpenRoot(s.resolve(r.URL.Path))
+ if err != nil {
+ http.Error(w, "not a directory", http.StatusNotFound)
+ return
+ }
+ defer dir.Close()
+ if r.ContentLength > s.maxUpload {
+ http.Error(w, "upload too large", http.StatusRequestEntityTooLarge)
+ return
+ }
+ body := &countedBody{ReadCloser: r.Body}
+ r.Body = http.MaxBytesReader(w, body, s.maxUpload)
+ mr, err := r.MultipartReader()
+ if err != nil {
+ http.Error(w, "bad upload: "+err.Error(), http.StatusBadRequest)
+ return
+ }
+ var files []stagedFile
+ defer func() {
+ for _, file := range files {
+ dir.Remove(file.temp)
+ }
+ }()
+ names := make(map[string]bool)
+ parts := 0
+ for {
+ part, err := mr.NextPart()
+ if err == io.EOF {
+ break
+ }
+ if err != nil {
+ writeUploadError(w, err, http.StatusBadRequest, body.bytes > s.maxUpload)
+ return
+ }
+ parts++
+ if parts > maxUploadParts {
+ http.Error(w, "too many upload parts", http.StatusBadRequest)
+ return
+ }
+ if part.FileName() == "" {
+ continue // not a file part
+ }
+ // The client supplied name is reduced to its base component so an
+ // upload can never escape the directory.
+ name := filepath.Base(part.FileName())
+ if name == "/" || name == "." || name == ".." || isTempName(name) {
+ http.Error(w, fmt.Sprintf("invalid filename: %q", part.FileName()),
+ http.StatusBadRequest)
+ return
+ }
+ if names[name] {
+ http.Error(w, fmt.Sprintf("duplicate filename: %q", name),
+ http.StatusBadRequest)
+ return
+ }
+ names[name] = true
+ file, err := stage(dir, name, part)
+ if err != nil {
+ status := http.StatusInternalServerError
+ if errors.Is(err, io.ErrUnexpectedEOF) {
+ status = http.StatusBadRequest
+ }
+ writeUploadError(w, err, status, body.bytes > s.maxUpload)
+ return
+ }
+ files = append(files, file)
+ }
+ if len(files) == 0 {
+ http.Error(w, "no files in upload", http.StatusBadRequest)
+ return
+ }
+ for _, file := range files {
+ if err := dir.Rename(file.temp, file.name); err != nil {
+ log.Printf("xf: commit upload %q: %v", file.name, err)
+ http.Error(w, "upload failed", http.StatusInternalServerError)
+ return
+ }
+ access.Printf("upload %q (%d bytes) from %s",
+ path.Join(r.URL.Path, file.name), file.size, clientIP(r))
+ }
+ http.Redirect(w, r, r.URL.Path, http.StatusSeeOther)
+}
+
+// stage streams one upload into a temporary file in its target directory, so
+// that the commit is an atomic rename.
+func stage(dir *os.Root, name string, src io.Reader) (stagedFile, error) {
+ mode := os.FileMode(0o600)
+ fi, err := dir.Lstat(name)
+ switch {
+ case os.IsNotExist(err):
+ case err != nil:
+ return stagedFile{}, err
+ case !fi.Mode().IsRegular():
+ return stagedFile{}, fmt.Errorf("refusing to replace non-regular file %q", name)
+ default:
+ mode = fi.Mode().Perm()
+ }
+
+ var random [16]byte
+ if _, err := rand.Read(random[:]); err != nil {
+ return stagedFile{}, err
+ }
+ tmpName := tempPrefix + hex.EncodeToString(random[:])
+ tmp, err := dir.OpenFile(tmpName, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0o600)
+ if err != nil {
+ return stagedFile{}, err
+ }
+
+ n, err := io.Copy(tmp, src)
+ if err == nil {
+ err = tmp.Chmod(mode)
+ }
+ if cerr := tmp.Close(); err == nil {
+ err = cerr
+ }
+ if err != nil {
+ dir.Remove(tmpName)
+ return stagedFile{}, err
+ }
+ return stagedFile{name: name, temp: tmpName, size: n}, nil
+}
+
+func isTempName(name string) bool {
+ if len(name) != len(tempPrefix)+32 || !strings.HasPrefix(name, tempPrefix) {
+ return false
+ }
+ _, err := hex.DecodeString(strings.TrimPrefix(name, tempPrefix))
+ return err == nil
+}
+
+func writeUploadError(w http.ResponseWriter, err error, status int, tooLarge bool) {
+ switch {
+ case tooLarge:
+ http.Error(w, "upload too large", http.StatusRequestEntityTooLarge)
+ case status == http.StatusInternalServerError:
+ log.Printf("xf: upload: %v", err)
+ http.Error(w, "upload failed", status)
+ default:
+ http.Error(w, "bad upload: "+err.Error(), status)
+ }
+}
+
+// recorder captures the status and size of a response for the access log.
+type recorder struct {
+ http.ResponseWriter
+ status int
+ bytes int64
+}
+
+func (rec *recorder) WriteHeader(code int) {
+ rec.status = code
+ rec.ResponseWriter.WriteHeader(code)
+}
+
+func (rec *recorder) Write(p []byte) (int, error) {
+ n, err := rec.ResponseWriter.Write(p)
+ rec.bytes += int64(n)
+ return n, err
+}
+
+func clientIP(r *http.Request) string {
+ host, _, err := net.SplitHostPort(r.RemoteAddr)
+ if err != nil {
+ return r.RemoteAddr
+ }
+ return host
+}
+
+func humanSize(n int64) string {
+ const unit = 1024
+ if n < unit {
+ return fmt.Sprintf("%d B", n)
+ }
+ div, exp := int64(unit), 0
+ for x := n / unit; x >= unit; x /= unit {
+ div *= unit
+ exp++
+ }
+ return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTPE"[exp])
+}
+
+const indexHTML = `<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>xf</title>
+<style>
+body { font-family: ui-monospace, monospace; max-width: 60rem; margin: 2rem auto; padding: 0 1rem; color: #111; }
+h1 { font-size: 1rem; font-weight: normal; color: #555; word-break: break-all; }
+form { margin: 1.5rem 0; padding: 1rem; border: 1px solid #ccc; }
+table { width: 100%; border-collapse: collapse; }
+td { text-align: left; padding: 0.3rem 0.5rem; border-bottom: 1px solid #eee; }
+td.size { text-align: right; white-space: nowrap; color: #555; }
+a { text-decoration: none; }
+a:hover { text-decoration: underline; }
+.empty { color: #999; }
+</style>
+</head>
+<body>
+<h1>xf: {{.Dir}}</h1>
+<form method="post" enctype="multipart/form-data">
+<input type="file" name="files" multiple required>
+<button type="submit">upload</button>
+</form>
+<table>
+<tbody>
+{{if .Parent}}
+<tr>
+<td><a href="../">../</a></td>
+<td class="size">-</td>
+</tr>
+{{end}}
+{{range .Entries}}
+<tr>
+<td><a href="{{.Href}}">{{.Name}}{{if .IsDir}}/{{end}}</a></td>
+<td class="size">{{.Size}}</td>
+</tr>
+{{else}}
+<tr><td class="empty" colspan="2">empty</td></tr>
+{{end}}
+</tbody>
+</table>
+</body>
+</html>
+`
diff --git a/main_test.go b/main_test.go
new file mode 100644
index 0000000..b767695
--- /dev/null
+++ b/main_test.go
@@ -0,0 +1,246 @@
+package main
+
+import (
+ "bytes"
+ "io"
+ "mime/multipart"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "syscall"
+ "testing"
+)
+
+type testUpload struct {
+ name string
+ body string
+}
+
+func TestRootConfinement(t *testing.T) {
+ rootDir := t.TempDir()
+ outsideDir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(outsideDir, "secret"), []byte("secret"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Symlink(outsideDir, filepath.Join(rootDir, "outside")); err != nil {
+ t.Fatal(err)
+ }
+
+ s := newTestServer(t, rootDir, 1<<20)
+ rec := httptest.NewRecorder()
+ s.handleGet(rec, httptest.NewRequest(http.MethodGet, "/outside/secret", nil))
+ if rec.Code != http.StatusNotFound || strings.Contains(rec.Body.String(), "secret") {
+ t.Fatalf("external symlink served: status %d, body %q", rec.Code, rec.Body.String())
+ }
+
+ req := newUploadRequest(t, "/outside/", []testUpload{{"written", "data"}})
+ rec = httptest.NewRecorder()
+ s.handleUpload(rec, req)
+ if rec.Code == http.StatusSeeOther {
+ t.Fatalf("upload through external symlink succeeded")
+ }
+ if _, err := os.Stat(filepath.Join(outsideDir, "written")); !os.IsNotExist(err) {
+ t.Fatalf("upload escaped root: %v", err)
+ }
+}
+
+func TestInternalSymlink(t *testing.T) {
+ rootDir := t.TempDir()
+ if err := os.Mkdir(filepath.Join(rootDir, "data"), 0o700); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(rootDir, "data", "file"), []byte("content"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Symlink("data", filepath.Join(rootDir, "internal")); err != nil {
+ t.Fatal(err)
+ }
+
+ s := newTestServer(t, rootDir, 1<<20)
+ rec := httptest.NewRecorder()
+ s.handleGet(rec, httptest.NewRequest(http.MethodGet, "/internal/file", nil))
+ if rec.Code != http.StatusOK || rec.Body.String() != "content" {
+ t.Fatalf("internal symlink failed: status %d, body %q", rec.Code, rec.Body.String())
+ }
+}
+
+func TestSpecialFileIsRejected(t *testing.T) {
+ rootDir := t.TempDir()
+ if err := syscall.Mkfifo(filepath.Join(rootDir, "fifo"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+
+ s := newTestServer(t, rootDir, 1<<20)
+ rec := httptest.NewRecorder()
+ s.handleGet(rec, httptest.NewRequest(http.MethodGet, "/fifo", nil))
+ if rec.Code != http.StatusForbidden {
+ t.Fatalf("status = %d, want %d", rec.Code, http.StatusForbidden)
+ }
+}
+
+func TestUploadLimitLeavesFilesUntouched(t *testing.T) {
+ rootDir := t.TempDir()
+ target := filepath.Join(rootDir, "file")
+ if err := os.WriteFile(target, []byte("old"), 0o640); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Chmod(target, 0o640); err != nil {
+ t.Fatal(err)
+ }
+
+ s := newTestServer(t, rootDir, 128)
+ req := newUploadRequest(t, "/", []testUpload{{"file", strings.Repeat("x", 256)}})
+ rec := httptest.NewRecorder()
+ s.handleUpload(rec, req)
+ if rec.Code != http.StatusRequestEntityTooLarge {
+ t.Fatalf("status = %d, want %d", rec.Code, http.StatusRequestEntityTooLarge)
+ }
+ assertFile(t, target, "old", 0o640)
+ assertNoTemps(t, rootDir)
+
+ req = newUploadRequest(t, "/", []testUpload{{"file", strings.Repeat("x", 256)}})
+ req.ContentLength = -1
+ rec = httptest.NewRecorder()
+ s.handleUpload(rec, req)
+ if rec.Code != http.StatusRequestEntityTooLarge {
+ t.Fatalf("chunked status = %d, want %d: %s",
+ rec.Code, http.StatusRequestEntityTooLarge, rec.Body.String())
+ }
+ assertFile(t, target, "old", 0o640)
+ assertNoTemps(t, rootDir)
+}
+
+func TestMalformedUploadLeavesFilesUntouched(t *testing.T) {
+ rootDir := t.TempDir()
+ target := filepath.Join(rootDir, "first")
+ if err := os.WriteFile(target, []byte("old"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+
+ req := newUploadRequest(t, "/", []testUpload{
+ {"first", "replacement"},
+ {"second", "new"},
+ })
+ body, err := io.ReadAll(req.Body)
+ if err != nil {
+ t.Fatal(err)
+ }
+ req.Body = io.NopCloser(bytes.NewReader(body[:len(body)-10]))
+ req.ContentLength = int64(len(body) - 10)
+
+ s := newTestServer(t, rootDir, 1<<20)
+ rec := httptest.NewRecorder()
+ s.handleUpload(rec, req)
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
+ }
+ assertFile(t, target, "old", 0o600)
+ if _, err := os.Stat(filepath.Join(rootDir, "second")); !os.IsNotExist(err) {
+ t.Fatalf("second file committed: %v", err)
+ }
+ assertNoTemps(t, rootDir)
+}
+
+func TestUploadModesAndTemporaryFiles(t *testing.T) {
+ rootDir := t.TempDir()
+ existing := filepath.Join(rootDir, "existing")
+ if err := os.WriteFile(existing, []byte("old"), 0o640); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Chmod(existing, 0o640); err != nil {
+ t.Fatal(err)
+ }
+
+ s := newTestServer(t, rootDir, 1<<20)
+ req := newUploadRequest(t, "/", []testUpload{
+ {"existing", "replacement"},
+ {"new", "content"},
+ })
+ rec := httptest.NewRecorder()
+ s.handleUpload(rec, req)
+ if rec.Code != http.StatusSeeOther {
+ t.Fatalf("status = %d, want %d: %s", rec.Code, http.StatusSeeOther, rec.Body.String())
+ }
+ assertFile(t, existing, "replacement", 0o640)
+ assertFile(t, filepath.Join(rootDir, "new"), "content", 0o600)
+ assertNoTemps(t, rootDir)
+
+ tempName := tempPrefix + strings.Repeat("0", 32)
+ if err := os.WriteFile(filepath.Join(rootDir, tempName), []byte("partial"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ rec = httptest.NewRecorder()
+ s.handleGet(rec, httptest.NewRequest(http.MethodGet, "/"+tempName, nil))
+ if rec.Code != http.StatusNotFound {
+ t.Fatalf("temporary file status = %d, want %d", rec.Code, http.StatusNotFound)
+ }
+ rec = httptest.NewRecorder()
+ s.handleGet(rec, httptest.NewRequest(http.MethodGet, "/", nil))
+ if strings.Contains(rec.Body.String(), tempName) || strings.Contains(rec.Body.String(), rootDir) {
+ t.Fatalf("listing exposed private path or temporary file: %q", rec.Body.String())
+ }
+}
+
+func newTestServer(t *testing.T, dir string, maxUpload int64) *server {
+ t.Helper()
+ root, err := os.OpenRoot(dir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { root.Close() })
+ return &server{root: root, maxUpload: maxUpload}
+}
+
+func newUploadRequest(t *testing.T, target string, files []testUpload) *http.Request {
+ t.Helper()
+ var body bytes.Buffer
+ w := multipart.NewWriter(&body)
+ for _, file := range files {
+ part, err := w.CreateFormFile("files", file.name)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := io.WriteString(part, file.body); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if err := w.Close(); err != nil {
+ t.Fatal(err)
+ }
+ req := httptest.NewRequest(http.MethodPost, target, bytes.NewReader(body.Bytes()))
+ req.Header.Set("Content-Type", w.FormDataContentType())
+ return req
+}
+
+func assertFile(t *testing.T, name, content string, mode os.FileMode) {
+ t.Helper()
+ data, err := os.ReadFile(name)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(data) != content {
+ t.Fatalf("%s contains %q, want %q", name, data, content)
+ }
+ fi, err := os.Stat(name)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if fi.Mode().Perm() != mode {
+ t.Fatalf("%s mode = %o, want %o", name, fi.Mode().Perm(), mode)
+ }
+}
+
+func assertNoTemps(t *testing.T, dir string) {
+ t.Helper()
+ entries, err := os.ReadDir(dir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, entry := range entries {
+ if isTempName(entry.Name()) {
+ t.Fatalf("temporary file remains: %s", entry.Name())
+ }
+ }
+}