diff --git a/v3/client.go b/v3/client.go index 1373283..df3a4d5 100644 --- a/v3/client.go +++ b/v3/client.go @@ -412,6 +412,13 @@ func (c *Client) readResponse(resp *Response) (stateFunc, error) { panic("grab: developer error: Response.HTTPResponse is nil") } + if resp.requestMethod() == "GET" && resp.DidResume { + if resp.HTTPResponse.StatusCode != http.StatusPartialContent { + resp.DidResume = false + resp.bytesResumed = 0 + } + } + // check expected size resp.sizeUnsafe = resp.HTTPResponse.ContentLength if resp.sizeUnsafe >= 0 { diff --git a/v3/client_test.go b/v3/client_test.go index 880c247..08062f6 100644 --- a/v3/client_test.go +++ b/v3/client_test.go @@ -318,6 +318,65 @@ func TestAutoResume(t *testing.T) { // TODO: test when existing file is corrupted } +func TestResumeFallsBackToFullWhenServerReturns200OK(t *testing.T) { + filename := filepath.Join(t.TempDir(), "testResume200OK") + partial := make([]byte, 100) + for i := range partial { + partial[i] = 0xAA + } + if err := os.WriteFile(filename, partial, 0666); err != nil { + t.Fatal(err) + } + + size := 512 + fullBytes := make([]byte, size) + for i := 0; i < size; i++ { + fullBytes[i] = byte(i) + } + + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Accept-Ranges", "bytes") + w.Header().Set("Content-Length", fmt.Sprintf("%d", size)) + if r.Method == "HEAD" { + w.WriteHeader(http.StatusOK) + return + } + // Server returns 200 OK with the full content, ignoring any Range header + w.WriteHeader(http.StatusOK) + w.Write(fullBytes) + })) + defer s.Close() + + req := mustNewRequest(filename, s.URL) + h := sha256.New() + h.Write(fullBytes) + req.SetChecksum(sha256.New(), h.Sum(nil), true) + + resp := mustDo(req) + testComplete(t, resp) + + if resp.DidResume { + t.Errorf("expected Response.DidResume to be false when server returned 200 OK") + } + if resp.BytesComplete() != int64(size) { + t.Errorf("expected BytesComplete %d, got %d", size, resp.BytesComplete()) + } + fi, err := os.Stat(filename) + if err != nil { + t.Fatal(err) + } + if fi.Size() != int64(size) { + t.Errorf("expected file size %d, got %d (file corrupted or appended)", size, fi.Size()) + } + data, err := os.ReadFile(filename) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(data, fullBytes) { + t.Errorf("downloaded file contents do not match expected full file") + } +} + func TestSkipExisting(t *testing.T) { filename := filepath.Join(t.TempDir(), "testSkipExisting") diff --git a/v3/pkg/grabtest/handler.go b/v3/pkg/grabtest/handler.go index efe829c..abad4f8 100644 --- a/v3/pkg/grabtest/handler.go +++ b/v3/pkg/grabtest/handler.go @@ -127,7 +127,15 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } // send header and status code - w.WriteHeader(h.statusCodeFunc(r)) + code := h.statusCodeFunc(r) + if code == http.StatusOK && h.acceptRanges && r.Header.Get("Range") != "" { + code = http.StatusPartialContent + w.Header().Set( + "Content-Range", + fmt.Sprintf("bytes %d-%d/%d", offset, h.contentLength-1, h.contentLength), + ) + } + w.WriteHeader(code) // send body if r.Method == "GET" { diff --git a/v3/response.go b/v3/response.go index 5a7981d..eeffda9 100644 --- a/v3/response.go +++ b/v3/response.go @@ -144,7 +144,10 @@ func (c *Response) BytesComplete() int64 { // complete, the average bytes/sec for the life of the download is returned. func (c *Response) BytesPerSecond() float64 { if c.IsComplete() { - return float64(c.transfer.N()) / c.Duration().Seconds() + if secs := c.Duration().Seconds(); secs > 0 { + return float64(c.transfer.N()) / secs + } + return 0 } return c.transfer.BPS() } @@ -164,6 +167,9 @@ func (c *Response) Progress() float64 { // the transfer is complete, the duration will be between the start and end of // the completed transfer process. func (c *Response) Duration() time.Duration { + if c.Start.IsZero() { + return 0 + } if c.IsComplete() { return c.End.Sub(c.Start) } @@ -180,11 +186,11 @@ func (c *Response) ETA() time.Time { } bt := c.BytesComplete() bps := c.transfer.BPS() - if bps == 0 { + if bps <= 0 || c.Size() <= 0 || c.Size() < bt { return time.Time{} } secs := float64(c.Size()-bt) / bps - return time.Now().Add(time.Duration(secs) * time.Second) + return time.Now().Add(time.Duration(secs * float64(time.Second))) } // Open blocks the calling goroutine until the underlying file transfer is diff --git a/v3/response_test.go b/v3/response_test.go index c642a14..4f774db 100644 --- a/v3/response_test.go +++ b/v3/response_test.go @@ -115,3 +115,43 @@ func TestResponseBytes(t *testing.T) { ) }) } + +func TestResponseStatsEdgeCases(t *testing.T) { + t.Run("ZeroStartDuration", func(t *testing.T) { + resp := &Response{} + if d := resp.Duration(); d != 0 { + t.Errorf("expected duration 0 for zero start, got %v", d) + } + }) + + t.Run("BytesPerSecondZeroDuration", func(t *testing.T) { + done := make(chan struct{}) + close(done) + resp := &Response{ + Done: done, + Start: time.Now(), + End: time.Now(), + } + if bps := resp.BytesPerSecond(); bps != 0 { + t.Errorf("expected 0 bps for 0 duration, got %v", bps) + } + }) + + t.Run("ETAUnknownSize", func(t *testing.T) { + resp := &Response{ + sizeUnsafe: -1, + } + if eta := resp.ETA(); !eta.IsZero() { + t.Errorf("expected zero ETA for unknown size, got %v", eta) + } + }) + + t.Run("ETAZeroOrNegativeBPS", func(t *testing.T) { + resp := &Response{ + sizeUnsafe: 100, + } + if eta := resp.ETA(); !eta.IsZero() { + t.Errorf("expected zero ETA when BPS is 0, got %v", eta) + } + }) +}