Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions formdata.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ func (v MimeTypeValidator) Validate(fh *multipart.FileHeader, location string) (
if err != nil {
return "", &ErrorDetail{Message: "Failed to open file", Location: location}
}
defer file.Close()

mimeType := fh.Header.Get("Content-Type")
if mimeType == "" {
Expand Down Expand Up @@ -178,6 +179,7 @@ func readFile(
}
contentType, validationErr := validator.Validate(fh, location)
if validationErr != nil {
f.Close()
return FormFile{}, validationErr
}
return FormFile{
Expand Down
84 changes: 84 additions & 0 deletions formdata_internal_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package huma

import (
"bytes"
"mime/multipart"
"runtime/debug"
"syscall"
"testing"

"github.com/stretchr/testify/require"
)

// limitFileDescriptors lowers the process soft RLIMIT_NOFILE and disables the
// garbage collector for the duration of the test. This makes leaked file
// handles observable: the GC finalizer would otherwise close unreachable
// handles and a high descriptor limit would absorb small leaks. The previous
// values are restored via t.Cleanup.
func limitFileDescriptors(t *testing.T) {
t.Helper()
var rl syscall.Rlimit
require.NoError(t, syscall.Getrlimit(syscall.RLIMIT_NOFILE, &rl))
orig := rl
if rl.Cur > 80 {
rl.Cur = 80
}
require.NoError(t, syscall.Setrlimit(syscall.RLIMIT_NOFILE, &rl))
origGC := debug.SetGCPercent(-1)
t.Cleanup(func() {
debug.SetGCPercent(origGC)
syscall.Setrlimit(syscall.RLIMIT_NOFILE, &orig)
})
}

// diskBackedFileHeader parses a one-file-part multipart body with a zero-byte
// memory threshold, so the part is stored in a temporary file on disk and each
// FileHeader.Open returns a real *os.File. The returned form must be cleaned
// up with RemoveAll.
func diskBackedFileHeader(t *testing.T, contentType string) (*multipart.Form, *multipart.FileHeader) {
t.Helper()
var buf bytes.Buffer
w := multipart.NewWriter(&buf)
part, err := w.CreateFormFile("file", "test.txt")
require.NoError(t, err)
_, err = part.Write([]byte("hello, world!"))
require.NoError(t, err)
require.NoError(t, w.Close())

form, err := multipart.NewReader(&buf, w.Boundary()).ReadForm(0)
require.NoError(t, err)
fh := form.File["file"][0]
fh.Header.Set("Content-Type", contentType)
return form, fh
}

func TestMimeTypeValidatorClosesFile(t *testing.T) {
form, fh := diskBackedFileHeader(t, "text/plain")
defer form.RemoveAll()
limitFileDescriptors(t)

validator := NewMimeTypeValidator(&Encoding{ContentType: "text/plain"})
for range 200 {
_, detail := validator.Validate(fh, "file")
// A leaked handle per call exhausts the descriptor limit within the
// loop; with the fix every handle is closed and this never fails.
require.Nil(t, detail, "Validate unexpectedly failed: %v", detail)
}
}

func TestReadFileClosesOnValidationError(t *testing.T) {
form, fh := diskBackedFileHeader(t, "text/plain")
defer form.RemoveAll()
limitFileDescriptors(t)

validator := NewMimeTypeValidator(&Encoding{ContentType: "image/png"})
for range 200 {
_, detail := readFile(fh, "file", validator)
// A leaked handle per call exhausts the descriptor limit within the
// loop and surfaces as "Failed to open file" instead of the expected
// mime-type error; with the fix every handle is closed and this never
// happens.
require.NotNil(t, detail)
require.Contains(t, detail.Message, "Invalid mime type")
}
}