From 598c9c1216d6b771791fab0e12781c2daeeb9a77 Mon Sep 17 00:00:00 2001 From: javi11 Date: Tue, 8 Sep 2026 08:51:05 +0200 Subject: [PATCH] fix(streaming): clamp a Range end past EOF instead of marking the file corrupted The WebDAV adapter hands the raw Range header to the file via context and getRequestRange parsed it without knowing the file size, so a legal "bytes=N-" request reached createUsenetReader unclamped. findSegmentForOffset(end) returned -1 for the out-of-range end, the lazy segment range came back empty, and that emptiness was treated as corrupt metadata: the file was marked FILE_STATUS_CORRUPTED and vanished from WebDAV. The parsed end is now clamped to FileSize-1, and createUsenetReader returns io.EOF for a start at or past EOF and clamps its end, so only a genuinely broken segment index can still produce the corruption verdict. --- .../nzbfilesystem/metadata_remote_file.go | 9 +++ internal/nzbfilesystem/range_past_eof_test.go | 76 +++++++++++++++++++ 2 files changed, 85 insertions(+) create mode 100644 internal/nzbfilesystem/range_past_eof_test.go diff --git a/internal/nzbfilesystem/metadata_remote_file.go b/internal/nzbfilesystem/metadata_remote_file.go index e010fcc67..b25c28614 100644 --- a/internal/nzbfilesystem/metadata_remote_file.go +++ b/internal/nzbfilesystem/metadata_remote_file.go @@ -1994,6 +1994,9 @@ func (mvf *MetadataVirtualFile) getRequestRange() (start, end int64) { if rangeStr, ok := mvf.ctx.Value(utils.RangeKey).(string); ok && rangeStr != "" { rangeHeader, err := utils.ParseRangeHeader(rangeStr) if err == nil && rangeHeader != nil { + if rangeHeader.End >= mvf.meta.FileSize { + rangeHeader.End = mvf.meta.FileSize - 1 + } mvf.originalRangeEnd = rangeHeader.End return rangeHeader.Start, rangeHeader.End } @@ -2022,6 +2025,12 @@ func (mvf *MetadataVirtualFile) createUsenetReader(ctx context.Context, start, e if len(mvf.meta.SegmentData) == 0 { return nil, ErrMissmatchedSegments } + if start >= mvf.meta.FileSize { + return nil, io.EOF + } + if end >= mvf.meta.FileSize { + end = mvf.meta.FileSize - 1 + } // Build segment offset index lazily on first read (thread-safe via sync.Once) mvf.segmentIndexOnce.Do(func() { diff --git a/internal/nzbfilesystem/range_past_eof_test.go b/internal/nzbfilesystem/range_past_eof_test.go new file mode 100644 index 000000000..9eafebac4 --- /dev/null +++ b/internal/nzbfilesystem/range_past_eof_test.go @@ -0,0 +1,76 @@ +package nzbfilesystem + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "testing" + + "github.com/kipsilabs/altmount/internal/config" + "github.com/kipsilabs/altmount/internal/testsupport/fakepool" + "github.com/kipsilabs/altmount/internal/testsupport/segments" + "github.com/kipsilabs/altmount/internal/utils" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// newRangeTestMVF builds a plain file whose ctx carries the given HTTP Range +// header (as the WebDAV adapter does) and whose health persistence is real, +// so a corruption verdict would leave a visible record. +func newRangeTestMVF(t *testing.T, rangeHeader string, n, segSize int) (*MetadataVirtualFile, func() bool) { + t.Helper() + repo, _, ms := setupStreamHealthEnv(t) + ctx := context.WithValue(context.Background(), utils.RangeKey, rangeHeader) + fp := fakepool.New() + configurePoolForFile(fp, n, segSize, fakepool.SegmentBehavior{}) + mvf := newTestMVF(t, ctx, fp, n, segSize, 4) + cfg := config.DefaultConfig() + mvf.healthRepository = repo + mvf.metadataService = ms + mvf.configGetter = func() *config.Config { return cfg } + + recorded := func() bool { + fh, err := repo.GetFileHealth(context.Background(), mvf.name) + require.NoError(t, err) + return fh != nil + } + return mvf, recorded +} + +func TestRangeEndPastEOFIsClampedNotCorrupted(t *testing.T) { + const n, segSize = 8, 64 << 10 + fileSize := int64(n * segSize) + start := int64(6*segSize + 100) + mvf, recorded := newRangeTestMVF(t, fmt.Sprintf("bytes=%d-%d", start, fileSize+1_000_000), n, segSize) + + _, err := mvf.Seek(start, io.SeekStart) + require.NoError(t, err) + + got, err := io.ReadAll(mvf) + var corrupted *CorruptedFileError + require.False(t, errors.As(err, &corrupted), "range past EOF must not be a corruption verdict: %v", err) + require.NoError(t, err) + + want := segments.FileBytes(n, segSize)[start:] + assert.True(t, bytes.Equal(got, want), "got %d bytes, want %d", len(got), len(want)) + assert.False(t, recorded(), "no health record must be written for a legal range") +} + +func TestRangeStartAtOrPastEOFIsEOFNotCorrupted(t *testing.T) { + const n, segSize = 4, 64 << 10 + fileSize := int64(n * segSize) + mvf, recorded := newRangeTestMVF(t, fmt.Sprintf("bytes=%d-%d", fileSize, fileSize+10), n, segSize) + + _, err := mvf.Seek(fileSize, io.SeekStart) + require.NoError(t, err) + + buf := make([]byte, 16) + n0, err := mvf.Read(buf) + var corrupted *CorruptedFileError + require.False(t, errors.As(err, &corrupted), "start at EOF must not be a corruption verdict: %v", err) + assert.Equal(t, 0, n0) + assert.ErrorIs(t, err, io.EOF) + assert.False(t, recorded(), "no health record must be written for a read at EOF") +}