diff options
| author | Lena <lena@omega> | 2026-07-01 00:00:00 +0000 |
|---|---|---|
| committer | Lena <lena@omega> | 2026-07-01 00:00:00 +0000 |
| commit | 6aca57a7cdcc2cd0e431146e61f3321100638baa (patch) | |
| tree | b4aaaa5528adf8127a9a7d805cbe722ebbdb86e9 | |
| download | xf-master.tar.gz | |
Serve a directory tree over HTTP through one embedded page: a listing
with download links and a multi-file upload form.
Filesystem access is confined to the served root with os.Root, and
only regular files are served. Uploads are bounded by size and part
count, and every file is staged in the target directory before any is
renamed into place, so a refused or interrupted request leaves the
directory untouched.
Standard library only, no third-party dependencies.
| -rw-r--r-- | .gitignore | 1 | ||||
| -rw-r--r-- | README | 62 | ||||
| -rw-r--r-- | go.mod | 3 | ||||
| -rw-r--r-- | main.go | 453 | ||||
| -rw-r--r-- | main_test.go | 246 |
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 @@ -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. @@ -0,0 +1,3 @@ +module xf + +go 1.25 @@ -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()) + } + } +} |