3 Commits
Author SHA1 Message Date
sneak fe612c8b74 Say plainly when the private key cannot decrypt a snapshot (closes #427)
check / check (push) Successful in 11m7s
Restore and deep verify now report "cannot decrypt this snapshot: the
private key in VAULTIK_AGE_SECRET_KEY is not one of the keys the backup
was encrypted to", naming the key source like the invalid-key error.
The doubled "creating decryption reader" prefix and the outer
"downloading snapshot database" / "failed to decrypt database" prefixes
are gone for this case.

A blob the key cannot decrypt, such as one stored before the key was
added as a recipient, now reports "cannot decrypt this blob: the
private key in VAULTIK_AGE_SECRET_KEY is not one of the keys the blob
was encrypted to" after the file and blob it belongs to.

Model: opus-5-5
2026-11-11 02:38:01 +00:00
clawbot 88a2aa7953 Shorten internal/vaultik tests under the race detector (closes #416)
check / check (push) Successful in 9m11s
The internal/vaultik tests took 36 to 56 seconds under go test -race and ran past the 90-second timeout when the host was busy. Tests that built the same backup now copy one shared backup, and the interruption tests use smaller sources. No assertion is weakened and no test is removed. Measured back to back on next under the same load, the package takes 55% of its former time.

Model: opus-5-5
2026-11-11 03:25:41 +01:00
clawbot 098ed80990 Keep only open work in docs/TODO.md (closes #440)
check / check (push) Failing after 22m25s
sneak ruled that TODO is not a history document. docs/TODO.md drops the Completed Steps section and every other record of past work, and keeps the open steps. The Workflow step that moved a finished Next Step into Completed Steps now deletes it.

Model: opus-5-5
2026-11-11 02:41:30 +01:00
8 changed files with 254 additions and 1240 deletions

No files matched your search

+3 -1205
View File
File diff suppressed because it is too large. Load diff
+31 -1
View File
@@ -22,6 +22,10 @@ var errBlobHashMismatch = errors.New("blob hash mismatch")
var errBlobNotFullyRead = errors.New(
"blob closed before fully read; hash not verified")
// errWrongPrivateKeyForBlob is returned when the configured key parses
// but is not one of the keys a blob was encrypted to.
var errWrongPrivateKeyForBlob = errors.New("cannot decrypt this blob")
// hashVerifyReader wraps a blobgen.Reader and verifies the double-SHA-256 hash
// of decrypted plaintext when Close is called. It reuses the hash that
// blobgen.Reader already computes internally via its TeeReader, avoiding
@@ -87,10 +91,14 @@ func (v *Vaultik) FetchAndDecryptBlob(
return nil, err
}
reader, err := blobgen.NewReader(rc, identities...)
reader, err := v.newBlobReader(rc, identities)
if err != nil {
_ = rc.Close()
if errors.Is(err, errWrongPrivateKeyForBlob) {
return nil, err
}
return nil, fmt.Errorf("creating blob reader: %w", err)
}
@@ -102,6 +110,28 @@ func (v *Vaultik) FetchAndDecryptBlob(
}, nil
}
// newBlobReader opens the decrypting reader for a blob. When none of the
// identities can decrypt it, the error says in plain words that the
// configured private key is not one of the keys the blob was encrypted
// to and names where the key was configured.
func (v *Vaultik) newBlobReader(
r io.Reader, identities []age.Identity,
) (*blobgen.Reader, error) {
blobReader, err := blobgen.NewReader(r, identities...)
if _, ok := errors.AsType[*age.NoIdentityMatchError](err); ok {
return nil, fmt.Errorf(
"%w: the private key in %s is not one of the keys the blob was encrypted to",
errWrongPrivateKeyForBlob, v.Config.AgeSecretKeySourceName())
}
if err != nil {
return nil, err
}
return blobReader, nil
}
// FetchBlob downloads a blob and returns a reader for the encrypted data.
// The reader is closed as soon as ctx is cancelled, which ends a read
// blocked inside it. A storage error is returned as is: restore and deep
+84 -12
View File
@@ -7,6 +7,7 @@ import (
"os"
"path/filepath"
"strings"
"sync"
"testing"
"github.com/spf13/afero"
@@ -24,19 +25,28 @@ import (
// that is not plugged in, cannot be listed. It is not an empty store, so
// no command may conclude from it that the local snapshots are gone.
// newFileDestinationVaultik returns a Vaultik that backs up the
// snapshot named "first" to a file:// destination at storeDir.
// newFileDestinationVaultik writes the fault source tree and returns a
// Vaultik that backs it up to a file:// destination at storeDir.
func newFileDestinationVaultik(
ctx context.Context, t *testing.T, storeDir string,
) (*vaultik.Vaultik, *database.Repositories) {
t.Helper()
osFs := afero.NewOsFs()
tempDir := t.TempDir()
dataDir := filepath.Join(tempDir, "src")
dbPath := filepath.Join(tempDir, "index.sqlite")
writeFaultSourceTree(t, afero.NewOsFs(), dataDir)
writeFaultSourceTree(t, osFs, dataDir)
return openFileDestinationVaultik(ctx, t, storeDir, dataDir,
filepath.Join(tempDir, "index.sqlite"))
}
// openFileDestinationVaultik returns a Vaultik that backs up dataDir to a
// file:// destination at storeDir, with its local index at dbPath. The
// index is closed when the test ends.
func openFileDestinationVaultik(
ctx context.Context, t *testing.T, storeDir, dataDir, dbPath string,
) (*vaultik.Vaultik, *database.Repositories) {
t.Helper()
store, err := storage.NewFileStorer(storeDir)
require.NoError(t, err)
@@ -48,22 +58,84 @@ func newFileDestinationVaultik(
repos := database.NewRepositories(db)
cfg := changedFileConfig(dataDir, dbPath)
return newBackupVaultik(ctx, cfg, store, repos, db, osFs), repos
return newBackupVaultik(ctx, cfg, store, repos, db, afero.NewOsFs()), repos
}
// firstBackup is one backup of the snapshot named "first" to a file://
// destination. dir holds its source tree in src, its store in store and
// its closed local index in index/index.sqlite. The first test that
// needs it makes it, and TestMain removes it. Every test that copies it
// backs up the same source tree, so no test may change that tree.
//
//nolint:gochecknoglobals // made once and shared by the tests that copy it
var firstBackup struct {
once sync.Once
dir string
ready bool
}
// TestMain removes firstBackup once every test has run.
func TestMain(m *testing.M) {
code := m.Run()
_ = os.RemoveAll(firstBackup.dir)
os.Exit(code)
}
// makeFirstBackup writes the fault source tree and backs it up as the
// snapshot named "first", all in a new directory that it records in
// firstBackup.
func makeFirstBackup(ctx context.Context, t *testing.T) {
t.Helper()
dir, err := os.MkdirTemp("", "vaultik-first-backup-")
require.NoError(t, err)
firstBackup.dir = dir
storeDir := filepath.Join(dir, "store")
dataDir := filepath.Join(dir, "src")
indexDir := filepath.Join(dir, "index")
require.NoError(t, os.Mkdir(storeDir, 0o750))
require.NoError(t, os.Mkdir(indexDir, 0o750))
writeFaultSourceTree(t, afero.NewOsFs(), dataDir)
v, _ := openFileDestinationVaultik(ctx, t, storeDir, dataDir,
filepath.Join(indexDir, "index.sqlite"))
require.NoError(t, backUp(v, "first"))
require.NoError(t, v.DB.Close())
firstBackup.ready = true
}
// backUpToFileDestination creates the directory storeDir, as a user must
// before the first backup, and backs up the snapshot named "first" to a
// file:// destination there. Everything the returned Vaultik prints
// after the backup goes to the returned buffer.
// before the first backup, and returns a Vaultik whose file://
// destination there holds a backup of the snapshot named "first". The
// store and the local index are copies of firstBackup's, which is faster
// than backing up in every test. The returned Vaultik backs up a source
// tree shared with every other caller, so no test may change that tree.
// Everything the returned Vaultik prints after the backup goes to the
// returned buffer.
func backUpToFileDestination(
ctx context.Context, t *testing.T, storeDir string,
) (*vaultik.Vaultik, *database.Repositories, *bytes.Buffer) {
t.Helper()
require.NoError(t, os.MkdirAll(storeDir, 0o750))
firstBackup.once.Do(func() { makeFirstBackup(ctx, t) })
require.True(t, firstBackup.ready, "another test failed to make firstBackup")
v, repos := newFileDestinationVaultik(ctx, t, storeDir)
require.NoError(t, backUp(v, "first"))
require.NoError(t, os.MkdirAll(storeDir, 0o750))
require.NoError(t, os.CopyFS(storeDir,
os.DirFS(filepath.Join(firstBackup.dir, "store"))))
indexDir := t.TempDir()
require.NoError(t, os.CopyFS(indexDir,
os.DirFS(filepath.Join(firstBackup.dir, "index"))))
v, repos := openFileDestinationVaultik(ctx, t, storeDir,
filepath.Join(firstBackup.dir, "src"),
filepath.Join(indexDir, "index.sqlite"))
out := &bytes.Buffer{}
v.Stdout = out
+7 -7
View File
@@ -46,11 +46,11 @@ import (
// slice out a few KB — currently the dominant cost in restore.
//
// The test deliberately constructs an adversarial scenario: three
// blobs A/B/C of ~6 MB each, nine files distributed across them, and
// path-ordered names that interleave the blobs (a1, b1, c1, a2, b2,
// c2, …) so naive path-order processing would touch every blob before
// finishing any of them.
// localitySource is one 1 MiB fixture file used by the locality test.
// blobs A/B/C of five 64 KiB chunks each, nine files distributed across
// them, and path-ordered names that interleave the blobs (a1, b1, c1,
// a2, b2, c2, …) so naive path-order processing would touch every blob
// before finishing any of them.
// localitySource is one 64 KiB fixture file used by the locality test.
type localitySource struct {
path string
data []byte
@@ -65,10 +65,10 @@ type localityCopy struct {
// localitySourceBytes is the size of each source file of the locality
// fixture.
const localitySourceBytes = 1024 * 1024
const localitySourceBytes = 64 * 1024
// buildLocalityFixture writes the adversarial source layout described
// in TestRestoreLocalityAndReadAt: 15 sources of 1 MiB each (which the
// in TestRestoreLocalityAndReadAt: 15 sources of 64 KiB each (which the
// backup packs into 3 blobs of 5 chunks) plus 9 interleaved-name copies
// (3 per blob group).
func buildLocalityFixture(
@@ -27,10 +27,10 @@ import (
// TestRestoreSweeperEvictsBlobs exercises the reference-counted blob
// disk cache eviction during restore.
//
// The scenario: 30 unique 1 MB random files plus 10 duplicates of those
// (40 files total, 30 MB of unique content) get backed up with a 10 MB
// blob_size_limit. After backup the snapshot's encrypted blobs are
// restored through Vaultik.Restore, and per-key Get counts on the
// The scenario: 30 unique 128 KiB random files plus 10 duplicates of
// those (40 files total, 3.75 MiB of unique content) get backed up with
// a 1 MiB blob_size_limit. After backup the snapshot's encrypted blobs
// are restored through Vaultik.Restore, and per-key Get counts on the
// storage layer are recorded. Each blob in the snapshot MUST be
// downloaded exactly once — re-downloads would mean the sweeper either
// evicted a blob that was still needed (LRU regression) or that the
@@ -41,7 +41,7 @@ import (
// original AND every duplicate referencing its chunks have been
// restored.
// buildSweeperFixture writes the source layout for the sweeper test: 30
// unique 1 MB random files plus 10 duplicates of a random subset. The
// unique 128 KiB random files plus 10 duplicates of a random subset. The
// PRNG seed is fixed so failures are reproducible; the entropy is what
// matters here — the FastCDC chunker needs realistic-looking data to
// pick chunk boundaries naturally. Returns the expected content by
@@ -51,7 +51,7 @@ func buildSweeperFixture(
) map[string][]byte {
t.Helper()
const fileSize = 1 * 1024 * 1024
const fileSize = 128 * 1024
rng := rand.New(rand.NewSource(42)) //nolint:gosec // G404: fixture data only
@@ -159,7 +159,7 @@ func runSweeperBackup(
require.NoError(t, err)
require.Equal(t, uniqueFiles+duplicateFiles, scanResult.FilesScanned)
require.Greater(t, scanResult.BlobsCreated, 1,
"30 MB of unique data at 10 MB blob size should yield multiple blobs")
"3.75 MiB of unique data at 1 MiB blob size should yield multiple blobs")
require.NoError(t, sm.CompleteSnapshot(ctx, snapshotID))
require.NoError(t, sm.ExportSnapshotMetadata(ctx, dbPath, snapshotID))
@@ -199,8 +199,8 @@ func TestRestoreSweeperEvictsBlobs(t *testing.T) {
expected := buildSweeperFixture(t, fs, dataDir, uniqueFiles, duplicateFiles)
chunkSize := int64(64 * 1024)
maxBlobSize := int64(10 * 1024 * 1024)
chunkSize := int64(16 * 1024)
maxBlobSize := int64(1024 * 1024)
storer, err := storage.NewFileStorer(storeDir)
require.NoError(t, err)
@@ -4,6 +4,8 @@ import (
"bytes"
"context"
"io"
"path"
"path/filepath"
"testing"
"filippo.io/age"
@@ -11,6 +13,7 @@ import (
"github.com/stretchr/testify/require"
"sneak.berlin/go/vaultik/internal/blobgen"
"sneak.berlin/go/vaultik/internal/config"
"sneak.berlin/go/vaultik/internal/log"
"sneak.berlin/go/vaultik/internal/snapshot"
"sneak.berlin/go/vaultik/internal/ui"
"sneak.berlin/go/vaultik/internal/vaultik"
@@ -74,3 +77,85 @@ func TestWrongPrivateKeyError(t *testing.T) {
err = v.RunDeepVerify(snapshotID, &vaultik.VerifyOptions{Deep: true})
require.EqualError(t, err, want)
}
// TestWrongPrivateKeyForBlobError backs up a file, then backs it up again
// from an empty local index with a second recipient added. The second
// snapshot reuses the stored blob, which stays encrypted to the first
// recipient only. With only the added key configured, restore and deep
// verify read the database but fail on the blob with one plain sentence
// that names where the key was configured.
func TestWrongPrivateKeyForBlobError(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
fs := afero.NewOsFs()
tempDir := t.TempDir()
dataDir := filepath.Join(tempDir, "source")
dataPath := filepath.Join(dataDir, "data.bin")
storeDir := t.TempDir()
secondDBPath := filepath.Join(tempDir, "second.sqlite")
chunkSize := int64(64 * 1024)
maxBlobSize := int64(512 * 1024)
require.NoError(t, fs.MkdirAll(dataDir, 0o755))
require.NoError(t, afero.WriteFile(fs, dataPath,
bytesPattern("key-", int(chunkSize)), 0o644))
ctx := context.Background()
runFileStorageBackup(ctx, t, fs, dataDir, storeDir,
filepath.Join(tempDir, "first.sqlite"), chunkSize, maxBlobSize)
env := setupDedupBackupEnv(
ctx, t, fs, storeDir, secondDBPath, chunkSize, maxBlobSize)
defer func() { _ = env.db.Close() }()
addedKey, err := age.GenerateX25519Identity()
require.NoError(t, err)
env.cfg.AgeRecipients = append(env.cfg.AgeRecipients,
addedKey.Recipient().String())
snapshotID, result := runDedupSnapshot(ctx, t, env.sm, env.makeScanner(),
env.cfg.Hostname, "second", dataDir, secondDBPath)
require.Zero(t, result.BlobsUploaded,
"the second backup should reuse the stored blob")
require.NoError(t, env.db.Close())
blobKeys, err := env.storer.List(ctx, "blobs/")
require.NoError(t, err)
require.Len(t, blobKeys, 1)
blobHash := path.Base(blobKeys[0])
env.cfg.AgeSecretKey = addedKey.String()
v := &vaultik.Vaultik{
Config: env.cfg,
Storage: env.storer,
Fs: fs,
Stdout: io.Discard,
Stderr: io.Discard,
UI: ui.NewWithColor(io.Discard, false),
}
v.SetContext(ctx)
const sentence = "cannot decrypt this blob: the private key in " +
"age_secret_key is not one of the keys the blob was encrypted to"
// Restore names the blob by the first 16 characters of its hash.
err = v.Restore(&vaultik.RestoreOptions{
SnapshotID: snapshotID,
TargetDir: t.TempDir(),
})
require.EqualError(t, err, "restoring "+dataPath+": downloading blob "+
blobHash[:16]+": "+sentence+
" (pass --skip-errors to continue past restore failures)")
err = v.RunDeepVerify(snapshotID, &vaultik.VerifyOptions{Deep: true})
require.EqualError(t, err,
"blob "+blobHash+" verification failed: "+sentence)
}
+30 -5
View File
@@ -187,6 +187,17 @@ func (e *listEnv) addRemoteRawTimestamp(
t.Helper()
remoteKey := snapshot.RemoteSnapshotKey(snapshotID)
e.putRemoteManifest(t, remoteKey,
encodeRemoteManifest(t, remoteKey, timestamp))
return remoteKey
}
// encodeRemoteManifest returns the encoded manifest of a one-blob
// snapshot whose remote key is remoteKey.
func encodeRemoteManifest(t *testing.T, remoteKey, timestamp string) []byte {
t.Helper()
manifest := &snapshot.Manifest{
// Note: the hashed key, never the human ID. That is precisely
// why a remote-only snapshot cannot be named.
@@ -202,11 +213,17 @@ func (e *listEnv) addRemoteRawTimestamp(
data, err := snapshot.EncodeManifest(manifest, 3)
require.NoError(t, err)
err = e.store.Put(context.Background(),
return data
}
// putRemoteManifest stores an encoded manifest on the destination store
// under remoteKey.
func (e *listEnv) putRemoteManifest(t *testing.T, remoteKey string, data []byte) {
t.Helper()
err := e.store.Put(context.Background(),
"metadata/"+remoteKey+"/manifest.json.zst", bytes.NewReader(data))
require.NoError(t, err)
return remoteKey
}
// Fixtures shared across the listing tests.
@@ -791,9 +808,17 @@ func TestListSnapshots_JSONReportsTruncation(t *testing.T) {
timestamp := time.Date(2026, 3, 2, 11, 22, 33, 0, time.UTC)
// One past the cap, so exactly one snapshot is omitted.
// One past the cap, so exactly one snapshot is omitted. The listing
// names a snapshot by the key its manifest is stored under, never by
// the manifest's own ID. So all of them share one encoded manifest:
// encoding 1001 took seconds.
data := encodeRemoteManifest(t,
snapshot.RemoteSnapshotKey("otherhost_bulk"),
timestamp.Format(time.RFC3339))
for i := range maxRemoteOnlyRowsForTest + 1 {
env.addRemote(t, fmt.Sprintf("otherhost_bulk_%04d", i), timestamp)
env.putRemoteManifest(t, snapshot.RemoteSnapshotKey(
fmt.Sprintf("otherhost_bulk_%04d", i)), data)
}
stderr := captureProcessStderr(t, func() {
+5 -1
View File
@@ -430,7 +430,11 @@ func (v *Vaultik) verifyBlob(
// the plaintext as it is read. A blob's hash — its remote name — is the
// double SHA-256 of that plaintext (see blobgen.DoubleSHA256), not of the
// encrypted bytes.
blobReader, err := blobgen.NewReader(reader, identities...)
blobReader, err := v.newBlobReader(reader, identities)
if errors.Is(err, errWrongPrivateKeyForBlob) {
return err
}
if err != nil {
return fmt.Errorf("failed to create blob reader: %w", err)
}