Skip to content

Commit fc6a020

Browse files
corylanouclaude
andcommitted
fix(restore): validate LTX file sizes before compaction
Add validation in Restore() to check that LTX files have valid sizes before attempting to open and compact them. Files with size less than the LTX header size (100 bytes) now return a clear error message identifying the problematic file instead of the cryptic "decode database: decode header: EOF" error. Fixes #848 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
1 parent ab4941a commit fc6a020

File tree

2 files changed

+69
-0
lines changed

2 files changed

+69
-0
lines changed

replica.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -435,6 +435,12 @@ func (r *Replica) Restore(ctx context.Context, opt RestoreOptions) (err error) {
435435
}()
436436

437437
for _, info := range infos {
438+
// Validate file size - must be at least header size to be readable
439+
if info.Size < ltx.HeaderSize {
440+
return fmt.Errorf("invalid ltx file: level=%d min=%s max=%s has size %d bytes (minimum %d)",
441+
info.Level, info.MinTXID, info.MaxTXID, info.Size, ltx.HeaderSize)
442+
}
443+
438444
r.Logger().Debug("opening ltx file for restore", "level", info.Level, "min", info.MinTXID, "max", info.MaxTXID)
439445

440446
// Add file to be compacted.

replica_test.go

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -391,6 +391,69 @@ func TestReplica_CalcRestorePlan(t *testing.T) {
391391
})
392392
}
393393

394+
func TestReplica_Restore_InvalidFileSize(t *testing.T) {
395+
db, sqldb := testingutil.MustOpenDBs(t)
396+
defer testingutil.MustCloseDBs(t, db, sqldb)
397+
398+
t.Run("EmptyFile", func(t *testing.T) {
399+
var c mock.ReplicaClient
400+
c.LTXFilesFunc = func(ctx context.Context, level int, seek ltx.TXID, useMetadata bool) (ltx.FileIterator, error) {
401+
if level == litestream.SnapshotLevel {
402+
return ltx.NewFileInfoSliceIterator([]*ltx.FileInfo{{
403+
Level: litestream.SnapshotLevel,
404+
MinTXID: 1,
405+
MaxTXID: 10,
406+
Size: 0, // Empty file - this should cause an error
407+
CreatedAt: time.Now(),
408+
}}), nil
409+
}
410+
return ltx.NewFileInfoSliceIterator(nil), nil
411+
}
412+
413+
r := litestream.NewReplicaWithClient(db, &c)
414+
outputPath := t.TempDir() + "/restored.db"
415+
416+
err := r.Restore(context.Background(), litestream.RestoreOptions{
417+
OutputPath: outputPath,
418+
})
419+
if err == nil {
420+
t.Fatal("expected error for empty file, got nil")
421+
}
422+
if !strings.Contains(err.Error(), "invalid ltx file") {
423+
t.Fatalf("expected 'invalid ltx file' error, got: %v", err)
424+
}
425+
})
426+
427+
t.Run("TruncatedFile", func(t *testing.T) {
428+
var c mock.ReplicaClient
429+
c.LTXFilesFunc = func(ctx context.Context, level int, seek ltx.TXID, useMetadata bool) (ltx.FileIterator, error) {
430+
if level == litestream.SnapshotLevel {
431+
return ltx.NewFileInfoSliceIterator([]*ltx.FileInfo{{
432+
Level: litestream.SnapshotLevel,
433+
MinTXID: 1,
434+
MaxTXID: 10,
435+
Size: 50, // Less than ltx.HeaderSize (100) - should cause an error
436+
CreatedAt: time.Now(),
437+
}}), nil
438+
}
439+
return ltx.NewFileInfoSliceIterator(nil), nil
440+
}
441+
442+
r := litestream.NewReplicaWithClient(db, &c)
443+
outputPath := t.TempDir() + "/restored.db"
444+
445+
err := r.Restore(context.Background(), litestream.RestoreOptions{
446+
OutputPath: outputPath,
447+
})
448+
if err == nil {
449+
t.Fatal("expected error for truncated file, got nil")
450+
}
451+
if !strings.Contains(err.Error(), "invalid ltx file") {
452+
t.Fatalf("expected 'invalid ltx file' error, got: %v", err)
453+
}
454+
})
455+
}
456+
394457
func TestReplica_ContextCancellationNoLogs(t *testing.T) {
395458
// This test verifies that context cancellation errors are not logged during shutdown.
396459
// The fix for issue #235 ensures that context.Canceled and context.DeadlineExceeded

0 commit comments

Comments
 (0)