diff --git a/replica.go b/replica.go index 72790753..9e32c38f 100644 --- a/replica.go +++ b/replica.go @@ -435,6 +435,12 @@ func (r *Replica) Restore(ctx context.Context, opt RestoreOptions) (err error) { }() for _, info := range infos { + // Validate file size - must be at least header size to be readable + if info.Size < ltx.HeaderSize { + return fmt.Errorf("invalid ltx file: level=%d min=%s max=%s has size %d bytes (minimum %d)", + info.Level, info.MinTXID, info.MaxTXID, info.Size, ltx.HeaderSize) + } + r.Logger().Debug("opening ltx file for restore", "level", info.Level, "min", info.MinTXID, "max", info.MaxTXID) // Add file to be compacted. @@ -477,7 +483,8 @@ func (r *Replica) Restore(ctx context.Context, opt RestoreOptions) (err error) { return } c.HeaderFlags = ltx.HeaderFlagNoChecksum - _ = pw.CloseWithError(c.Compact(ctx)) + compactErr := c.Compact(ctx) + _ = pw.CloseWithError(compactErr) }() dec := ltx.NewDecoder(pr) diff --git a/replica_test.go b/replica_test.go index 706a1e3f..7d648109 100644 --- a/replica_test.go +++ b/replica_test.go @@ -391,6 +391,69 @@ func TestReplica_CalcRestorePlan(t *testing.T) { }) } +func TestReplica_Restore_InvalidFileSize(t *testing.T) { + db, sqldb := testingutil.MustOpenDBs(t) + defer testingutil.MustCloseDBs(t, db, sqldb) + + t.Run("EmptyFile", func(t *testing.T) { + var c mock.ReplicaClient + c.LTXFilesFunc = func(ctx context.Context, level int, seek ltx.TXID, useMetadata bool) (ltx.FileIterator, error) { + if level == litestream.SnapshotLevel { + return ltx.NewFileInfoSliceIterator([]*ltx.FileInfo{{ + Level: litestream.SnapshotLevel, + MinTXID: 1, + MaxTXID: 10, + Size: 0, // Empty file - this should cause an error + CreatedAt: time.Now(), + }}), nil + } + return ltx.NewFileInfoSliceIterator(nil), nil + } + + r := litestream.NewReplicaWithClient(db, &c) + outputPath := t.TempDir() + "/restored.db" + + err := r.Restore(context.Background(), litestream.RestoreOptions{ + OutputPath: outputPath, + }) + if err == nil { + t.Fatal("expected error for empty file, got nil") + } + if !strings.Contains(err.Error(), "invalid ltx file") { + t.Fatalf("expected 'invalid ltx file' error, got: %v", err) + } + }) + + t.Run("TruncatedFile", func(t *testing.T) { + var c mock.ReplicaClient + c.LTXFilesFunc = func(ctx context.Context, level int, seek ltx.TXID, useMetadata bool) (ltx.FileIterator, error) { + if level == litestream.SnapshotLevel { + return ltx.NewFileInfoSliceIterator([]*ltx.FileInfo{{ + Level: litestream.SnapshotLevel, + MinTXID: 1, + MaxTXID: 10, + Size: 50, // Less than ltx.HeaderSize (100) - should cause an error + CreatedAt: time.Now(), + }}), nil + } + return ltx.NewFileInfoSliceIterator(nil), nil + } + + r := litestream.NewReplicaWithClient(db, &c) + outputPath := t.TempDir() + "/restored.db" + + err := r.Restore(context.Background(), litestream.RestoreOptions{ + OutputPath: outputPath, + }) + if err == nil { + t.Fatal("expected error for truncated file, got nil") + } + if !strings.Contains(err.Error(), "invalid ltx file") { + t.Fatalf("expected 'invalid ltx file' error, got: %v", err) + } + }) +} + func TestReplica_ContextCancellationNoLogs(t *testing.T) { // This test verifies that context cancellation errors are not logged during shutdown. // The fix for issue #235 ensures that context.Canceled and context.DeadlineExceeded