Compare commits

..
1 Commits
Author SHA1 Message Date
sneak cf0f08586d Reject a metadata database truncated to the age header and nonce (closes #152)
check / check (pull_request) Successful in 2m35s
An object holding just the age header and its 16-byte nonce decrypts
without error: the truncated read surfaces as io.ErrUnexpectedEOF at the
age layer, which the zstd decoder maps to a clean EOF at frame start.
blobgen then reported zero bytes and no error, so a truncated stream was
indistinguishable from a valid empty one, and the metadata database
export slipped through — restore built a fresh schema on the empty file
and reported success.

blobgen.Reader.Read now, on EOF, reads once more from the age reader and
surfaces io.ErrUnexpectedEOF unless that read is (0, io.EOF), the state a
genuine end leaves. downloadSnapshotDB additionally rejects a zero-length
decrypted database before any schema is built.

Model: opus-4-8
2026-09-22 15:51:13 +00:00
2 changed files with 39 additions and 2 deletions
@@ -7,8 +7,10 @@ import (
"path/filepath"
"testing"
"filippo.io/age"
"github.com/spf13/afero"
"github.com/stretchr/testify/require"
"sneak.berlin/go/vaultik/internal/blobgen"
"sneak.berlin/go/vaultik/internal/database"
)
@@ -56,6 +58,37 @@ func TestMaterializeSnapshotDBPrivateDir(t *testing.T) {
require.Error(t, err, "materialized snapshot database must be read-only")
}
// TestMaterializeSnapshotDBRejectsCompleteEmptyStream proves the written == 0
// guard rejects a genuinely empty but complete metadata object: a real age
// header, nonce, and final tag encrypting zero plaintext bytes. The truncation
// case is stopped earlier by the reader (io.ErrUnexpectedEOF) and never reaches
// this branch, so it needs its own input. This complete stream decrypts to zero
// bytes with a clean EOF, passes the reader, and must be refused as empty rather
// than accepted as a valid zero-table database. Reverting the guard lets the
// empty file open as a fresh schema and the test fails.
func TestMaterializeSnapshotDBRejectsCompleteEmptyStream(t *testing.T) {
identity, err := age.GenerateX25519Identity()
require.NoError(t, err)
var stream bytes.Buffer
w, err := age.Encrypt(&stream, identity.Recipient())
require.NoError(t, err)
require.NoError(t, w.Close())
blobReader, err := blobgen.NewReader(bytes.NewReader(stream.Bytes()), identity)
require.NoError(t, err)
t.Cleanup(func() { _ = blobReader.Close() })
t.Setenv("TMPDIR", t.TempDir())
v := &Vaultik{ctx: context.Background(), Fs: afero.NewOsFs()}
_, _, err = v.materializeSnapshotDB(blobReader)
require.ErrorIs(t, err, errEmptySnapshotDB)
}
// TestMaterializeSnapshotDBRemovesDirOnOpenFailure proves a failed open
// leaves no temp directory behind.
func TestMaterializeSnapshotDBRemovesDirOnOpenFailure(t *testing.T) {
@@ -20,7 +20,11 @@ import (
// the snapshot's db.zst.age with a stream cut right after the age header and
// its 16-byte nonce. age.Decrypt still accepts such an object and the zstd
// decoder turns the truncated read into a clean EOF, so before the fix restore
// built a fresh empty schema and reported success. Restore must now fail.
// built a fresh empty schema and reported success. Restore must now fail with
// io.ErrUnexpectedEOF, the error the reader raises for a truncated object.
// Asserting that specific error pins the reader fix: without it the truncation
// yields an empty database, which the identity check rejects for an unrelated
// reason, and this test would pass anyway.
func TestRestoreRejectsTruncatedMetadataDB(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
@@ -77,5 +81,5 @@ func TestRestoreRejectsTruncatedMetadataDB(t *testing.T) {
TargetDir: restoreDir,
Verify: true,
})
require.Error(t, err)
require.ErrorIs(t, err, io.ErrUnexpectedEOF)
}