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

View file

@ -6,6 +6,8 @@ import (
"errors"
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
_ "modernc.org/sqlite"
@ -85,6 +87,10 @@ func ConnectDB(initDB bool) error {
_ = db.Close()
return err
}
if err := secureSQLiteFiles(Cfg.DBPath); err != nil {
_ = db.Close()
return err
}
schema := []string{
`CREATE TABLE IF NOT EXISTS accounts(
id INTEGER PRIMARY KEY AUTOINCREMENT,
@ -361,7 +367,7 @@ func ensureDBFile(path string, initDB bool) error {
if info.IsDir() {
return fmt.Errorf("DB %q ist ein Verzeichnis", path)
}
return nil
return chmodPrivateFile(path)
}
if !errors.Is(err, os.ErrNotExist) {
return err
@ -373,7 +379,57 @@ func ensureDBFile(path string, initDB bool) error {
if err != nil {
return err
}
return f.Close()
if err := f.Close(); err != nil {
return err
}
return chmodPrivateFile(path)
}
func secureSQLiteFiles(path string) error {
directory := filepath.Dir(path)
entries, err := os.ReadDir(directory)
if err != nil {
return fmt.Errorf("scan SQLite directory %q: %w", directory, err)
}
for _, entry := range entries {
if entry.Type()&os.ModeSymlink != 0 || !isSQLiteDataFile(entry.Name()) {
continue
}
info, err := entry.Info()
if err != nil {
return fmt.Errorf("inspect SQLite file %q: %w", filepath.Join(directory, entry.Name()), err)
}
if !info.Mode().IsRegular() {
continue
}
candidate := filepath.Join(directory, entry.Name())
if err := chmodPrivateFile(candidate); err != nil {
return fmt.Errorf("secure SQLite file %q: %w", candidate, err)
}
}
return nil
}
func chmodPrivateFile(path string) error {
if err := os.Chmod(path, 0o600); err != nil {
return err
}
if runtime.GOOS == "windows" {
return nil
}
info, err := os.Stat(path)
if err != nil {
return err
}
if permissions := info.Mode().Perm(); permissions&0o077 != 0 {
return fmt.Errorf("permissions=%#o after chmod 0600", permissions)
}
return nil
}
func isSQLiteDataFile(name string) bool {
name = strings.ToLower(name)
return strings.HasSuffix(name, ".db") || strings.HasSuffix(name, ".db-wal") || strings.HasSuffix(name, ".db-shm")
}
func ensureAppUserColumns() error {
@ -724,56 +780,72 @@ func GetCopyIdentityStateWithFolderAlias(accountID int64, folder, legacyFolder s
if legacyFolder != "" && legacyFolder != folder {
folders = append(folders, legacyFolder)
}
aliases := []string{identity.MessageID, identity.LegacyMessageID}
aliases = append(aliases, identity.LegacyMessageIDs...)
if identity.MessageID == "" && identity.BodySHA256 != "" {
aliases = append(aliases, "sha256:"+identity.BodySHA256)
}
uniqueAliases := make([]string, 0, len(aliases))
seenAliases := map[string]bool{}
for _, alias := range aliases {
alias = strings.TrimSpace(alias)
if alias == "" || seenAliases[alias] {
continue
}
seenAliases[alias] = true
uniqueAliases = append(uniqueAliases, alias)
}
// Precise rows always win. For Message-ID-less mail the hash is the whole
// identity, irrespective of which legacy key happened to be stored beside it.
// For mail with an ID, historical normalizations are precise aliases only
// when the canonical body hash is identical.
var mboxDone, targetDone int
for _, candidateFolder := range folders {
var mbox, target int
var err error
if identity.MessageID == "" {
err = DB.QueryRow(`SELECT COALESCE(MAX(mbox_done),0), COALESCE(MAX(target_done),0) FROM copied
var mbox, target int
err := DB.QueryRow(`SELECT COALESCE(MAX(mbox_done),0), COALESCE(MAX(target_done),0) FROM copied
WHERE account_id=? AND folder=? AND body_sha256=?`, accountID, candidateFolder, identity.BodySHA256).Scan(&mbox, &target)
} else {
err = DB.QueryRow(`SELECT COALESCE(MAX(mbox_done),0), COALESCE(MAX(target_done),0) FROM copied
if err != nil {
return CopyState{}, err
}
mboxDone |= mbox
targetDone |= target
continue
}
for _, alias := range uniqueAliases {
var mbox, target int
err := DB.QueryRow(`SELECT COALESCE(MAX(mbox_done),0), COALESCE(MAX(target_done),0) FROM copied
WHERE account_id=? AND folder=? AND message_id=? AND body_sha256=?`,
accountID, candidateFolder, identity.MessageID, identity.BodySHA256).Scan(&mbox, &target)
accountID, candidateFolder, alias, identity.BodySHA256).Scan(&mbox, &target)
if err != nil {
return CopyState{}, err
}
mboxDone |= mbox
targetDone |= target
}
if err != nil {
return CopyState{}, err
}
mboxDone |= mbox
targetDone |= target
}
if mboxDone != 0 || targetDone != 0 {
return CopyState{MboxDone: mboxDone != 0, TargetDone: targetDone != 0}, nil
}
// Once a Message-ID has precise archive identities, a different body with
// that same ID is new and must not be hidden by the old wildcard row.
if identity.MessageID != "" && identity.BodySHA256 != "" {
if len(uniqueAliases) != 0 && identity.BodySHA256 != "" {
var precise int
for _, candidateFolder := range folders {
var count int
if err := DB.QueryRow(`SELECT count(*) FROM copied WHERE account_id=? AND folder=? AND message_id=? AND body_sha256<>''`,
accountID, candidateFolder, identity.MessageID).Scan(&count); err != nil {
return CopyState{}, err
for _, alias := range uniqueAliases {
var count int
if err := DB.QueryRow(`SELECT count(*) FROM copied WHERE account_id=? AND folder=? AND message_id=? AND body_sha256<>''`,
accountID, candidateFolder, alias).Scan(&count); err != nil {
return CopyState{}, err
}
precise += count
}
precise += count
}
if precise > 0 {
return CopyState{}, nil
}
}
aliases := []string{identity.MessageID, identity.LegacyMessageID}
if identity.MessageID == "" && identity.BodySHA256 != "" {
aliases = append(aliases, "sha256:"+identity.BodySHA256)
}
seen := map[string]bool{}
for _, alias := range aliases {
alias = strings.TrimSpace(alias)
if alias == "" || seen[alias] {
continue
}
seen[alias] = true
for _, alias := range uniqueAliases {
for _, candidateFolder := range folders {
var mbox, target int
err := DB.QueryRow(`SELECT COALESCE(MAX(mbox_done),0), COALESCE(MAX(target_done),0) FROM copied
@ -875,21 +947,25 @@ func ReplaceMboxIndex(accountID int64, folder string, entries []MboxIndexEntry,
if err := aliasRows.Close(); err != nil {
return err
}
legacyIDsBySeq := map[int]string{}
type storedIndexIdentity struct {
messageID string
bodySHA256 string
}
storedIdentityBySeq := map[int]storedIndexIdentity{}
for _, candidateFolder := range folderAliases {
rows, err := tx.Query(`SELECT seq, message_id FROM mbox_index WHERE account_id=? AND folder=?`, accountID, candidateFolder)
rows, err := tx.Query(`SELECT seq, message_id, body_sha256 FROM mbox_index WHERE account_id=? AND folder=?`, accountID, candidateFolder)
if err != nil {
return err
}
for rows.Next() {
var seq int
var messageID string
if err := rows.Scan(&seq, &messageID); err != nil {
var identity storedIndexIdentity
if err := rows.Scan(&seq, &identity.messageID, &identity.bodySHA256); err != nil {
_ = rows.Close()
return err
}
if _, exists := legacyIDsBySeq[seq]; !exists || candidateFolder == folder {
legacyIDsBySeq[seq] = messageID
if _, exists := storedIdentityBySeq[seq]; !exists || candidateFolder == folder {
storedIdentityBySeq[seq] = identity
}
}
if err := rows.Err(); err != nil {
@ -907,6 +983,15 @@ func ReplaceMboxIndex(accountID int64, folder string, entries []MboxIndexEntry,
entry.AccountID = accountID
entry.Folder = folder
entry.Seq = i
storedIdentity := storedIdentityBySeq[i]
// The IMAP envelope and a later RFC-822 header parse can represent the
// same malformed Message-ID differently. If the canonical body at the
// same archive position is identical, retain the already committed ID so
// a rebuild is an exact metadata restore. Never carry it across a body
// change, because that could attach an old identity to different content.
if storedIdentity.bodySHA256 != "" && storedIdentity.bodySHA256 == entry.BodySHA256 {
entry.MessageID = storedIdentity.messageID
}
if entry.MessageID == "" && entry.BodySHA256 == "" {
continue
}
@ -921,7 +1006,7 @@ func ReplaceMboxIndex(accountID int64, folder string, entries []MboxIndexEntry,
// pipeline the target append happened before the mbox append, so recovered
// records are complete in both stages. Marking them prevents a later watch
// run from appending duplicates to either destination.
state, found, err := copyStateForReindex(tx, entry.AccountID, folderAliases, entry, legacyIDsBySeq[i])
state, found, err := copyStateForReindex(tx, entry.AccountID, folderAliases, entry, storedIdentity.messageID)
if err != nil {
return err
}