Files
jcoffey-dev 12ec0c3fd4 Rename the module to the Coffey-Labs organisation
The repositories moved off LINUXexpert-org. Here that is not a
documentation change: the old organisation was the module path, so it is
declared in go.mod and repeated in every internal import.

Leaving it would have been worse than a stale link. GitHub redirects the
repository, but a go.mod whose module line disagrees with the path it was
fetched from is an error rather than a redirect, so `go get` on the new
address would have failed against the old declaration.

go.mod, 34 files of imports, and the repository links in README and
ARCHITECTURE. go mod tidy leaves go.sum untouched -- no dependency moved,
only our own path.
2026-08-30 15:24:18 -07:00

244 lines
9.4 KiB
Go

// SPDX-FileCopyrightText: 2026 Coffey Labs
// SPDX-License-Identifier: GPL-3.0-or-later
package validate
import (
"context"
"fmt"
"sort"
"strings"
"github.com/Coffey-Labs/stalwart-migrator/internal/checkpoint"
"github.com/Coffey-Labs/stalwart-migrator/internal/stalwartapi"
)
// MailboxDelta is one mailbox whose message count didn't match between the
// pre- and post-migration snapshots.
type MailboxDelta struct {
Account string
Mailbox string
Before int
After int
}
// ContentIntegrityResult is the outcome of comparing a pre-migration
// snapshot against a freshly captured post-migration one - the
// no-data-loss check described in ARCHITECTURE.md §4.7.
//
// MessageCountsCompared is the field that decides how much this result is
// worth, and it is not always true. Stalwart 0.15.x exposes no per-mailbox
// message counts at any endpoint, and its impersonation login (which 0.16
// offers) returns 401 - so on the 0.15/0.16 boundary migration there are no
// "before" counts to compare and this check can only assert that every
// account and domain survived. Saying so is the whole point: an earlier
// version of this comparison iterated the before-counts map, found it
// empty, and reported "all message counts match" having checked nothing.
type ContentIntegrityResult struct {
AccountsChecked int
MailboxesChecked int
MissingAccounts []string // present before, not found after (even accounting for the email-address rewrite)
MessageCountMismatches []MailboxDelta // present both before and after, but with a different message count
MissingDomains []string // present before, absent after
MessageCountsCompared bool // false when the source version could not report counts
// AccountsVisibleAfter is how many accounts the migrated instance was
// willing to show whoever asked. An account without management
// permission is shown far fewer than exist - so a short list is a
// statement about the reader, not about the data.
AccountsVisibleAfter int
}
// OK reports whether everything that must match did: no account and no mail
// went missing. Read it together with MessageCountsCompared: OK with that
// false means "the directory survived", not "no mail was lost".
//
// Domains are deliberately not part of this. What the two versions call a
// domain differs across the 0.15/0.16 boundary — principals on one side,
// Domain objects on the other, with aliases and account-less domains
// counted differently — and we have already been caught once reporting a
// migration that lost nothing as having lost domains. A disagreement there
// is worth showing an operator; it is not worth failing a migration over,
// where a missing account is.
func (r ContentIntegrityResult) OK() bool {
return len(r.MissingAccounts) == 0 && len(r.MessageCountMismatches) == 0
}
// DomainsOK reports whether every domain seen before the migration is still
// listed after it.
func (r ContentIntegrityResult) DomainsOK() bool {
return len(r.MissingDomains) == 0
}
// Inconclusive reports that the migrated instance showed fewer accounts than
// existed before, which makes any "missing" finding unsafe to believe.
//
// Enumeration is permission-scoped: an account that authenticates but holds
// no management role is shown only what it may see, and a v0.16 migration
// does not always carry an admin role across - so the account that read the
// "before" side may not have the same reach afterwards. Observed on a clone
// of production: every account survived, and the comparison called one of
// them lost because the reader could no longer see it. Reporting data loss
// on that evidence is worse than reporting nothing.
func (r ContentIntegrityResult) Inconclusive() bool {
return len(r.MissingAccounts) > 0 && r.AccountsVisibleAfter < r.AccountsChecked
}
func (r ContentIntegrityResult) String() string {
var b strings.Builder
if r.MessageCountsCompared {
fmt.Fprintf(&b, "content integrity: %d account(s), %d mailbox(es) checked", r.AccountsChecked, r.MailboxesChecked)
} else {
fmt.Fprintf(&b, "content integrity: %d account(s) and their domains checked; MESSAGE COUNTS NOT COMPARED "+
"(this migration's source version reports no per-mailbox counts, so no-data-loss is NOT verified here - "+
"only that every account and domain survived)", r.AccountsChecked)
}
// Everything below is a finding, so return early only when there is
// nothing at all to report - domains included, even though they no
// longer fail the run. A warning nobody can read is not a warning.
if r.OK() && r.DomainsOK() {
if r.MessageCountsCompared {
b.WriteString(", all message counts match")
}
return b.String()
}
for _, a := range r.MissingAccounts {
fmt.Fprintf(&b, "; MISSING ACCOUNT %s", a)
}
for _, d := range r.MissingDomains {
fmt.Fprintf(&b, "; MISSING DOMAIN %s", d)
}
for _, d := range r.MessageCountMismatches {
fmt.Fprintf(&b, "; MESSAGE COUNT MISMATCH %s/%s: %d before, %d after", d.Account, d.Mailbox, d.Before, d.After)
}
return b.String()
}
// compareContentIntegrity captures a fresh snapshot via client and compares
// it against before, matching accounts by exact name first and falling
// back to the local part (the text before "@") since Stalwart's v0.16
// migration rewrites bare usernames to full email addresses
// (UPGRADING/v0_16.md: "the migration script automatically assigns the
// default domain to accounts lacking one") - an exact-string comparison
// alone would misreport every rewritten account as missing.
func compareContentIntegrity(ctx context.Context, client *stalwartapi.Client, before *checkpoint.PreflightSnapshot) (*ContentIntegrityResult, error) {
after, err := client.AccountSnapshot(ctx)
if err != nil {
return nil, fmt.Errorf("capture post-migration snapshot: %w", err)
}
result := &ContentIntegrityResult{MessageCountsCompared: len(before.MailboxCounts) > 0}
// The set of accounts to verify comes from whichever pre-migration
// facts the source version was able to report: mailbox counts when it
// had them (0.16+), otherwise the used-quota map, which the 0.15.x REST
// principal list does populate. Deriving it from MailboxCounts alone
// would silently check nothing on a 0.15 source.
beforeAccountSet := map[string]bool{}
for a := range before.MailboxCounts {
beforeAccountSet[a] = true
}
for a := range before.UsedQuota {
beforeAccountSet[a] = true
}
beforeAccounts := make([]string, 0, len(beforeAccountSet))
for a := range beforeAccountSet {
beforeAccounts = append(beforeAccounts, a)
}
sort.Strings(beforeAccounts)
// Likewise for the "after" side: an account that exists but whose
// mailboxes couldn't be read still counts as present.
afterAccounts := map[string]bool{}
for a := range after.MailboxCounts {
afterAccounts[a] = true
}
for a := range after.UsedQuota {
afterAccounts[a] = true
}
for a := range after.MailboxErrors {
afterAccounts[a] = true
}
result.AccountsVisibleAfter = len(afterAccounts)
for _, d := range before.Domains {
if !containsDomain(after.Domains, d) {
result.MissingDomains = append(result.MissingDomains, d)
}
}
for _, beforeAccount := range beforeAccounts {
result.AccountsChecked++
if !accountPresent(afterAccounts, beforeAccount) {
result.MissingAccounts = append(result.MissingAccounts, beforeAccount)
continue
}
if !result.MessageCountsCompared {
continue // nothing to compare counts against; presence is all this source could give
}
afterMailboxes, found := after.MailboxCounts[beforeAccount]
if !found {
afterMailboxes, found = findByLocalPart(after.MailboxCounts, beforeAccount)
}
if !found {
result.MissingAccounts = append(result.MissingAccounts, beforeAccount)
continue
}
afterByName := make(map[string]int, len(afterMailboxes))
for _, m := range afterMailboxes {
afterByName[m.Mailbox] = m.Messages
}
beforeMailboxes := append([]checkpoint.MailboxCount(nil), before.MailboxCounts[beforeAccount]...)
sort.Slice(beforeMailboxes, func(i, j int) bool { return beforeMailboxes[i].Mailbox < beforeMailboxes[j].Mailbox })
for _, bm := range beforeMailboxes {
result.MailboxesChecked++
afterCount, ok := afterByName[bm.Mailbox]
if !ok || afterCount != bm.Messages {
result.MessageCountMismatches = append(result.MessageCountMismatches, MailboxDelta{
Account: beforeAccount, Mailbox: bm.Mailbox, Before: bm.Messages, After: afterCount,
})
}
}
}
return result, nil
}
// accountPresent matches an account against the post-migration set the
// same way findByLocalPart does, so the v0.16 rewrite of bare usernames
// into full addresses doesn't read as every account having vanished.
func accountPresent(afterAccounts map[string]bool, beforeAccount string) bool {
if afterAccounts[beforeAccount] {
return true
}
local := strings.SplitN(beforeAccount, "@", 2)[0]
for a := range afterAccounts {
if strings.SplitN(a, "@", 2)[0] == local {
return true
}
}
return false
}
// containsDomain matches domains exactly; unlike account names, the
// migration does not rewrite them.
func containsDomain(domains []string, want string) bool {
for _, d := range domains {
if d == want {
return true
}
}
return false
}
func findByLocalPart(mailboxCounts map[string][]stalwartapi.MailboxCount, beforeAccount string) ([]stalwartapi.MailboxCount, bool) {
local := strings.SplitN(beforeAccount, "@", 2)[0]
for afterAccount, mb := range mailboxCounts {
if strings.SplitN(afterAccount, "@", 2)[0] == local {
return mb, true
}
}
return nil, false
}