From 30febb78b80b37451afa35a5ef6fda5112e692ef Mon Sep 17 00:00:00 2001 From: PJ Date: Sat, 22 Aug 2026 21:07:58 +0530 Subject: [PATCH] fix(replay): a truncated index is a 500, not a partial 200 readAll reimplemented io.ReadAll and swallowed every error but fs.ErrInvalid, returning the bytes it had managed to read with a nil error, so a half-written index.html was indistinguishable from a whole one. io.ReadAll surfaces the directory-handle case fs.ErrInvalid covered as well. --- internal/replay/server.go | 22 ++------------- internal/replay/server_test.go | 49 ++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 20 deletions(-) diff --git a/internal/replay/server.go b/internal/replay/server.go index 7af9b10..df89e42 100644 --- a/internal/replay/server.go +++ b/internal/replay/server.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "io/fs" "net/http" "path" @@ -233,7 +234,7 @@ func serveIndex(responseWriter http.ResponseWriter, assets fs.FS) { return } defer file.Close() - body, err := readAll(file) + body, err := io.ReadAll(file) if err != nil { http.Error(responseWriter, err.Error(), http.StatusInternalServerError) return @@ -242,25 +243,6 @@ func serveIndex(responseWriter http.ResponseWriter, assets fs.FS) { _, _ = responseWriter.Write(body) } -func readAll(file fs.File) ([]byte, error) { - const initialCapacity = 4 * 1024 - buffer := make([]byte, 0, initialCapacity) - chunk := make([]byte, 4*1024) - for { - read, err := file.Read(chunk) - if read > 0 { - buffer = append(buffer, chunk[:read]...) - } - if err != nil { - if errors.Is(err, fs.ErrInvalid) { - return nil, err - } - break - } - } - return buffer, nil -} - func writeJSON(responseWriter http.ResponseWriter, status int, payload any) { responseWriter.Header().Set("Content-Type", "application/json") responseWriter.WriteHeader(status) diff --git a/internal/replay/server_test.go b/internal/replay/server_test.go index 3db4fad..2bfade1 100644 --- a/internal/replay/server_test.go +++ b/internal/replay/server_test.go @@ -371,6 +371,55 @@ func TestAssets_FallbackToIndexHTML(t *testing.T) { } } +type truncatedIndexFS struct { + prefix []byte +} + +func (assets truncatedIndexFS) Open(name string) (fs.File, error) { + if name != "index.html" { + return nil, fs.ErrNotExist + } + return &truncatedIndexFile{prefix: assets.prefix}, nil +} + +type truncatedIndexFile struct { + prefix []byte + consumed bool +} + +func (file *truncatedIndexFile) Stat() (fs.FileInfo, error) { return nil, fs.ErrInvalid } + +func (file *truncatedIndexFile) Read(destination []byte) (int, error) { + if file.consumed { + return 0, io.ErrUnexpectedEOF + } + file.consumed = true + return copy(destination, file.prefix), nil +} + +func (file *truncatedIndexFile) Close() error { return nil } + +func TestAssets_TruncatedIndexIsNotServedAsOK(t *testing.T) { + prefix := []byte(`
`) + server, err := NewServer(ServerOptions{ + RunsDirectory: t.TempDir(), + AssetsFS: truncatedIndexFS{prefix: prefix}, + }) + if err != nil { + t.Fatal(err) + } + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, "/", nil) + server.Handler().ServeHTTP(recorder, request) + + if recorder.Code != http.StatusInternalServerError { + t.Errorf("status = %d, want 500", recorder.Code) + } + if strings.Contains(recorder.Body.String(), string(prefix)) { + t.Errorf("served the partial index body: %q", recorder.Body.String()) + } +} + func TestAssets_API404DoesNotFallThrough(t *testing.T) { server, _ := newFixtureServer(t) recorder := httptest.NewRecorder()