Add verified snapshots and harden archive identity

This commit is contained in:
DonVoo 2026-07-17 00:37:02 +02:00
parent c6de214ac8
commit 68b9cee880
9 changed files with 1372 additions and 52 deletions

209
backend/14-snapshot_test.go Normal file
View file

@ -0,0 +1,209 @@
package backend
import (
"encoding/json"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
)
func TestSnapshotUsesDatabaseWatermarkWhileArchiveKeepsGrowing(t *testing.T) {
for _, compression := range []string{"none", "zstd"} {
t.Run(compression, func(t *testing.T) {
account, writer, restore := setupSnapshotTest(t, compression)
defer restore()
first := RawMessage{
MessageID: "first@example.com",
Body: []byte("Message-ID: <first@example.com>\r\nSubject: First\r\n\r\nFirst body\r\nFrom quoted\r\n"),
InternalDate: time.Unix(1_700_000_000, 0),
}
firstInfo := appendSnapshotTestMessage(t, account, writer, "INBOX", first)
snapshotRoot := filepath.Join(t.TempDir(), "snapshot")
var secondInfo MboxAppendInfo
err := createSnapshot(snapshotRoot, func() error {
second := RawMessage{
MessageID: "second@example.com",
Body: []byte("Message-ID: <second@example.com>\r\nSubject: Second\r\n\r\nSecond body"),
InternalDate: time.Unix(1_700_000_100, 0),
}
secondInfo = appendSnapshotTestMessage(t, account, writer, "INBOX", second)
return nil
})
if err != nil {
t.Fatal(err)
}
manifest := readSnapshotTestManifest(t, snapshotRoot)
if runtime.GOOS != "windows" {
databaseInfo, err := os.Stat(filepath.Join(snapshotRoot, filepath.FromSlash(manifest.DatabasePath)))
if err != nil {
t.Fatal(err)
}
if permissions := databaseInfo.Mode().Perm(); permissions != 0o600 {
t.Fatalf("snapshot database permissions=%#o, want 0600", permissions)
}
}
if manifest.IndexRecords != 1 || manifest.PreciseCopied != 1 || len(manifest.Files) != 1 {
t.Fatalf("snapshot crossed DB boundary: %#v", manifest)
}
if manifest.Files[0].Bytes != firstInfo.FileOffset+firstInfo.FrameLen {
t.Fatalf("snapshot bytes=%d, want first committed boundary=%d", manifest.Files[0].Bytes, firstInfo.FileOffset+firstInfo.FrameLen)
}
sourceInfo, err := os.Stat(secondInfo.Path)
if err != nil {
t.Fatal(err)
}
if sourceInfo.Size() <= manifest.Files[0].Bytes {
t.Fatalf("source did not grow beyond snapshot boundary: source=%d snapshot=%d", sourceInfo.Size(), manifest.Files[0].Bytes)
}
snapshotMbox := filepath.Join(snapshotRoot, filepath.FromSlash(manifest.Files[0].Path))
messages, err := readMboxMessages(snapshotMbox)
if err != nil {
t.Fatal(err)
}
if len(messages) != 1 || !strings.Contains(string(messages[0]), "First body") {
t.Fatalf("snapshot messages=%d, expected only first message", len(messages))
}
if err := VerifySnapshot(snapshotRoot); err != nil {
t.Fatalf("verify valid snapshot: %v", err)
}
var liveIndex int
if err := DB.QueryRow(`SELECT count(*) FROM mbox_index WHERE account_id=?`, account.ID).Scan(&liveIndex); err != nil {
t.Fatal(err)
}
if liveIndex != 2 {
t.Fatalf("snapshot verification altered live database: index=%d", liveIndex)
}
f, err := os.OpenFile(snapshotMbox, os.O_APPEND|os.O_WRONLY, 0)
if err != nil {
t.Fatal(err)
}
if _, err := f.Write([]byte("tamper")); err != nil {
_ = f.Close()
t.Fatal(err)
}
if err := f.Close(); err != nil {
t.Fatal(err)
}
if err := VerifySnapshot(snapshotRoot); err == nil {
t.Fatal("verification accepted a modified mbox")
}
})
}
}
func TestSnapshotRefusesKnownMissingArchiveIdentity(t *testing.T) {
account, _, restore := setupSnapshotTest(t, "none")
defer restore()
identity := MessageIdentity{MessageID: "missing@example.com", BodySHA256: bodySHA256([]byte("missing"))}
if err := MarkIdentityCopied(account.ID, "INBOX", identity); err != nil {
t.Fatal(err)
}
target := filepath.Join(t.TempDir(), "refused")
err := CreateSnapshot(target)
if err == nil || !strings.Contains(err.Error(), "no archive index") {
t.Fatalf("snapshot error=%v, want missing archive refusal", err)
}
if _, statErr := os.Stat(target); !os.IsNotExist(statErr) {
t.Fatalf("refused snapshot unexpectedly finalized: %v", statErr)
}
}
func TestSnapshotDoesNotOverwriteExistingTarget(t *testing.T) {
_, _, restore := setupSnapshotTest(t, "none")
defer restore()
target := filepath.Join(t.TempDir(), "existing")
if err := os.Mkdir(target, 0o700); err != nil {
t.Fatal(err)
}
sentinel := filepath.Join(target, "keep")
if err := os.WriteFile(sentinel, []byte("keep"), 0o600); err != nil {
t.Fatal(err)
}
if err := CreateSnapshot(target); err == nil {
t.Fatal("snapshot overwrote an existing target")
}
if got, err := os.ReadFile(sentinel); err != nil || string(got) != "keep" {
t.Fatalf("existing target changed: %q %v", got, err)
}
}
func setupSnapshotTest(t *testing.T, compression string) (Account, *MboxWriter, func()) {
t.Helper()
oldCfg, oldDB := Cfg, DB
root := t.TempDir()
Cfg = Config{
DBPath: filepath.Join(root, "mail-graveyard.db"),
MboxRoot: filepath.Join(root, "backup"),
MboxCompression: compression,
}
DB = nil
if err := ConnectDB(true); err != nil {
t.Fatal(err)
}
restore := func() {
if DB != nil {
_ = DB.Close()
}
Cfg, DB = oldCfg, oldDB
}
if err := SaveAccount(Account{
Name: "snapshot-account", SrcHost: "source.example", SrcPort: 993, SrcSecurity: "tls", SrcUser: "source@example.com", SrcPass: "x",
DstHost: "target.example", DstPort: 993, DstSecurity: "tls", DstUser: "target@example.com", DstPass: "x",
MboxDir: "snapshot@example.com", Active: true,
}); err != nil {
restore()
t.Fatal(err)
}
account, err := GetAccount("snapshot-account")
if err != nil {
restore()
t.Fatal(err)
}
writer, err := NewMboxWriter(filepath.Join(Cfg.MboxRoot, account.MboxDir))
if err != nil {
restore()
t.Fatal(err)
}
return account, writer, restore
}
func appendSnapshotTestMessage(t *testing.T, account Account, writer *MboxWriter, folder string, message RawMessage) MboxAppendInfo {
t.Helper()
identity := identityForRawMessage(message)
info, err := writer.Append(folder, message)
if err != nil {
t.Fatal(err)
}
if err := SaveMboxIndex(MboxIndexEntry{
AccountID: account.ID, Folder: safeMboxName(folder), MessageID: identity.MessageID, BodySHA256: identity.BodySHA256,
FileOffset: info.FileOffset, FrameLen: info.FrameLen, InnerOffset: info.InnerOffset, InnerLen: info.InnerLen,
}); err != nil {
t.Fatal(err)
}
if err := UpdateMboxIndexState(account.ID, safeMboxName(folder), info.FileOffset+info.FrameLen); err != nil {
t.Fatal(err)
}
if err := MarkIdentityCopied(account.ID, safeMboxName(folder), identity); err != nil {
t.Fatal(err)
}
return info
}
func readSnapshotTestManifest(t *testing.T, root string) SnapshotManifest {
t.Helper()
b, err := os.ReadFile(filepath.Join(root, "snapshot-manifest.json"))
if err != nil {
t.Fatal(err)
}
var manifest SnapshotManifest
if err := json.Unmarshal(b, &manifest); err != nil {
t.Fatal(err)
}
return manifest
}