Compare commits

..
1 Commits
Author SHA1 Message Date
sneak 03f126edc1 Reject a metadata database truncated to the age header and nonce (closes #152)
check / check (pull_request) Successful in 2m33s
An object holding exactly the age header plus its 16-byte nonce decrypts
without error: age.Decrypt succeeds, and on the first read the age stream's
io.ErrUnexpectedEOF is mapped by the zstd decoder to a clean io.EOF at frame
start. blobgen.Reader.Read then reported zero bytes and no error, so a
truncated stream was indistinguishable from a valid empty one. Blobs are
caught by the Close-time hash check, but the metadata database export was not:
restore built a fresh schema on the empty file and exited reporting 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 behind, so an empty payload still round-trips to empty with
no error. downloadSnapshotDB additionally rejects a zero-length decrypted
database before OpenReadOnly applies a schema.

On next the decrypted-DB identity check already rejects an empty database, so
the restore-level test guards the required end-to-end property; the blobgen
tests are the ones that fail without the reader change.

Model: opus-4-8
2026-09-22 15:12:48 +00:00
2 changed files with 2 additions and 39 deletions
@@ -7,10 +7,8 @@ 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"
)
@@ -58,37 +56,6 @@ 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,11 +20,7 @@ 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 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.
// built a fresh empty schema and reported success. Restore must now fail.
func TestRestoreRejectsTruncatedMetadataDB(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
@@ -81,5 +77,5 @@ func TestRestoreRejectsTruncatedMetadataDB(t *testing.T) {
TargetDir: restoreDir,
Verify: true,
})
require.ErrorIs(t, err, io.ErrUnexpectedEOF)
require.Error(t, err)
}