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
109 lines
3.3 KiB
Go
109 lines
3.3 KiB
Go
package vaultik //nolint:testpackage // inspects unexported snapshot-db materialization
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"os"
|
|
"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"
|
|
)
|
|
|
|
// genuineSnapshotDBBytes returns the on-disk bytes of a real snapshot
|
|
// database (the full schema applied).
|
|
func genuineSnapshotDBBytes(t *testing.T) []byte {
|
|
t.Helper()
|
|
|
|
path := filepath.Join(t.TempDir(), "snapshot.db")
|
|
|
|
db, err := database.New(context.Background(), path)
|
|
require.NoError(t, err)
|
|
require.NoError(t, db.Close())
|
|
|
|
data, err := os.ReadFile(path) //nolint:gosec // G304: test-controlled temp path
|
|
require.NoError(t, err)
|
|
|
|
return data
|
|
}
|
|
|
|
// TestMaterializeSnapshotDBPrivateDir proves the decrypted database lands
|
|
// in a private (0700) directory and opens read-only.
|
|
func TestMaterializeSnapshotDBPrivateDir(t *testing.T) {
|
|
dbData := genuineSnapshotDBBytes(t)
|
|
|
|
t.Setenv("TMPDIR", t.TempDir())
|
|
|
|
v := &Vaultik{ctx: context.Background(), Fs: afero.NewOsFs()}
|
|
|
|
db, dir, err := v.materializeSnapshotDB(bytes.NewReader(dbData))
|
|
require.NoError(t, err)
|
|
|
|
t.Cleanup(func() {
|
|
_ = db.Close()
|
|
_ = os.RemoveAll(dir)
|
|
})
|
|
|
|
info, err := os.Stat(dir)
|
|
require.NoError(t, err)
|
|
require.Equal(t, os.FileMode(0o700), info.Mode().Perm(),
|
|
"snapshot database directory must not be world-readable")
|
|
|
|
_, err = db.Conn().ExecContext(context.Background(),
|
|
"CREATE TABLE probe_readonly (x)")
|
|
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) {
|
|
base := t.TempDir()
|
|
|
|
t.Setenv("TMPDIR", base)
|
|
|
|
v := &Vaultik{ctx: context.Background(), Fs: afero.NewOsFs()}
|
|
|
|
_, _, err := v.materializeSnapshotDB(
|
|
bytes.NewReader([]byte("this is not a sqlite database")))
|
|
require.Error(t, err)
|
|
|
|
entries, rerr := os.ReadDir(base)
|
|
require.NoError(t, rerr)
|
|
require.Empty(t, entries, "temp directory left behind after open failure")
|
|
}
|