Fail closed on unreadable manifests instead of losing blobs (closes #157)
check / check (pull_request) Successful in 2m59s
check / check (pull_request) Successful in 2m59s
Prune learned which blobs are in use by reading every snapshot's manifest, but merely logged and skipped one it could not download or decode. Blobs referenced only by that snapshot then looked unreferenced and were deleted, with a zero exit — and `snapshot create --prune` runs this unattended. collectReferencedBlobs now errors, naming the remote key, so prune deletes nothing and exits non-zero. Manifest generation likewise skipped a blob whose lookup failed or was missing, yielding a manifest short of what the snapshot needs; it now fails. Deep verify only warned when the manifest omitted a database blob; it now fails on any divergence. Docs corrected. Model: opus-4-8
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
//nolint:testpackage // exercises the unexported generateBlobManifest
|
||||
package snapshot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/vaultik/internal/config"
|
||||
"sneak.berlin/go/vaultik/internal/database"
|
||||
"sneak.berlin/go/vaultik/internal/log"
|
||||
"sneak.berlin/go/vaultik/internal/types"
|
||||
)
|
||||
|
||||
// TestGenerateBlobManifest_MissingBlobFails is the regression guard for
|
||||
// issue #157: a blob the snapshot references but that is absent from the
|
||||
// blobs table used to be logged and skipped, yielding a manifest with
|
||||
// fewer blobs than the snapshot needs. Since prune trusts the manifest
|
||||
// alone, that omitted blob would be deleted at the next prune. Manifest
|
||||
// generation must fail instead.
|
||||
func TestGenerateBlobManifest_MissingBlobFails(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
dbPath := filepath.Join(t.TempDir(), "snapshot.db")
|
||||
|
||||
db, err := database.New(ctx, dbPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
repos := database.NewRepositories(db)
|
||||
|
||||
// A real blob row satisfies the snapshot_blobs foreign key on
|
||||
// blob_id; the snapshot then references a different, absent hash.
|
||||
presentBlob := &database.Blob{
|
||||
ID: types.NewBlobID(),
|
||||
Hash: types.BlobHash("present-blob-hash"),
|
||||
CreatedTS: time.Now().Truncate(time.Second),
|
||||
}
|
||||
require.NoError(t, repos.Blobs.Create(ctx, nil, presentBlob))
|
||||
|
||||
snap := &database.Snapshot{
|
||||
ID: "testhost_home_2026-05-01T00:00:00Z",
|
||||
Hostname: "testhost",
|
||||
}
|
||||
require.NoError(t, repos.Snapshots.Create(ctx, nil, snap))
|
||||
require.NoError(t, repos.Snapshots.AddBlob(ctx, nil,
|
||||
snap.ID.String(), presentBlob.ID, types.BlobHash("absent-blob-hash")))
|
||||
|
||||
require.NoError(t, db.Close())
|
||||
|
||||
sm := &SnapshotManager{
|
||||
config: &config.Config{CompressionLevel: 3},
|
||||
fs: afero.NewOsFs(),
|
||||
}
|
||||
|
||||
_, err = sm.generateBlobManifest(ctx, dbPath, snap.ID.String())
|
||||
require.Error(t, err, "manifest generation must fail on a missing blob")
|
||||
assert.Contains(t, err.Error(), "absent-blob-hash")
|
||||
}
|
||||
Reference in New Issue
Block a user