Add verified snapshots and harden archive identity
This commit is contained in:
parent
c6de214ac8
commit
68b9cee880
9 changed files with 1372 additions and 52 deletions
209
backend/14-snapshot_test.go
Normal file
209
backend/14-snapshot_test.go
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue