test / test (push) Canceled after 0s
Cyrus SASL 2.1.28 opens sasldb2 with no Berkeley DB environment, so with
no locks (sasldb/db_berkeley.c, berkeleydb_open(): db_create(mbdb, NULL,
0)); saslpasswd2 puts and deletes in the file itself (_sasldb_putdata()),
while smtpd opens and closes it at every AUTH (_sasldb_getdata()) and the
backup copies it whenever it runs. Either could read a half-written page:
a spurious 535, or a torn sasldb2 in a backup whose restore breaks every
application's AUTH (assumptions C-S3, C-Q3).
SASLDB.Set and Delete now run saslpasswd2 on sasldb2.new - a byte copy of
the database, or no file when there is none yet (saslpasswd2 creates it,
DB_CREATE) - then chmod 0640, fsync, rename over sasldb2; a failed run
removes the copy and leaves the database; a copy a killed panel left is
removed first. A mutex keeps two changes of the panel from copying the
same old file. The backup needs no lock after all (the plan said one): it
reads each file through one descriptor, and a rename leaves that file
whole; the walk now leaves out a file that vanished between listing and
open (a writer's temporary file), while the SQLite snapshot's absence
stays an error. CHANGELOG also gains the entries of f2ad699 (supervision),
written then without them.
Tests: the copy holds the database's bytes, saslpasswd2 never gets the
live path, a reader of the old file keeps reading it (Linux), mode 0640,
nothing left behind; a failed change leaves the database; the first
account creates it. Stand (2026-10-15, make e2e, exit 0): new
sasldb_changed_by_rename - the inode of sasldb2 changes across one
password change (it cannot be the old one's: both exist at the copy),
then 35 AUTHs of app1 from three clients during 104 password changes of
another application, none refused; afterwards 640 panel selfpost, no copy
left in /data/sasl, the last password authenticates. That cannot prove a
race absent; it guards the mechanism. (A first run compared the inode
after 15 changes and found it equal: Linux reuses a freed inode number.)
go vet and go test ./... pass on the server (24 packages).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
115 lines
3.5 KiB
Go
115 lines
3.5 KiB
Go
package app
|
|
|
|
import (
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"testing"
|
|
)
|
|
|
|
// A change is made to a copy and renamed over the database: saslpasswd2
|
|
// never gets the live file, and a reader that opened the old file keeps
|
|
// reading it whole (assumptions C-S3).
|
|
func TestSASLChangeGoesThroughACopy(t *testing.T) {
|
|
dir := t.TempDir()
|
|
db := filepath.Join(dir, "sasldb2")
|
|
if err := os.WriteFile(db, []byte("old database"), 0o640); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// smtpd in the middle of a lookup. Windows refuses a rename over an open
|
|
// file; the container is Linux, where the reader keeps the old inode.
|
|
var reader *os.File
|
|
if runtime.GOOS != "windows" {
|
|
var err error
|
|
if reader, err = os.Open(db); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer reader.Close()
|
|
}
|
|
|
|
s := NewSASLDB(db, "mail.example.com")
|
|
var gotPath string
|
|
s.run = func(args []string, stdin []byte) error {
|
|
gotPath = args[3]
|
|
before, err := os.ReadFile(gotPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if string(before) != "old database" {
|
|
t.Errorf("the copy holds %q, want the database's bytes", before)
|
|
}
|
|
return os.WriteFile(gotPath, []byte("new database"), 0o600)
|
|
}
|
|
if err := s.Set("alerts", "pw"); err != nil {
|
|
t.Fatalf("Set: %v", err)
|
|
}
|
|
if gotPath == db {
|
|
t.Fatal("saslpasswd2 was given the live database")
|
|
}
|
|
if got, _ := os.ReadFile(db); string(got) != "new database" {
|
|
t.Fatalf("database after the change: %q", got)
|
|
}
|
|
if runtime.GOOS != "windows" {
|
|
if fi, _ := os.Stat(db); fi.Mode().Perm() != 0o640 {
|
|
t.Fatalf("database mode %v, want 0640 for Postfix's group", fi.Mode().Perm())
|
|
}
|
|
old := make([]byte, 64)
|
|
n, _ := reader.ReadAt(old, 0)
|
|
if string(old[:n]) != "old database" {
|
|
t.Fatalf("a reader of the old file read %q", old[:n])
|
|
}
|
|
}
|
|
if _, err := os.Stat(db + ".new"); !errors.Is(err, os.ErrNotExist) {
|
|
t.Fatalf("the copy is left behind: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestSASLFailedChangeLeavesTheDatabase(t *testing.T) {
|
|
dir := t.TempDir()
|
|
db := filepath.Join(dir, "sasldb2")
|
|
if err := os.WriteFile(db, []byte("old database"), 0o640); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// A copy a killed panel left behind is not taken for the database.
|
|
if err := os.WriteFile(db+".new", []byte("stale"), 0o640); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
s := NewSASLDB(db, "mail.example.com")
|
|
s.run = func(args []string, stdin []byte) error {
|
|
if b, _ := os.ReadFile(args[2]); string(b) != "old database" {
|
|
t.Errorf("the copy holds %q", b)
|
|
}
|
|
_ = os.WriteFile(args[2], []byte("half"), 0o640)
|
|
return errors.New("saslpasswd2: exit status 1")
|
|
}
|
|
if err := s.Delete("alerts"); err == nil {
|
|
t.Fatal("Delete succeeded with saslpasswd2 failing")
|
|
}
|
|
if got, _ := os.ReadFile(db); string(got) != "old database" {
|
|
t.Fatalf("database after a failed change: %q", got)
|
|
}
|
|
if _, err := os.Stat(db + ".new"); !errors.Is(err, os.ErrNotExist) {
|
|
t.Fatalf("the copy is left behind: %v", err)
|
|
}
|
|
}
|
|
|
|
// The first account: there is no database to copy, and saslpasswd2 creates
|
|
// one at the path it is given.
|
|
func TestSASLFirstAccountCreatesTheDatabase(t *testing.T) {
|
|
db := filepath.Join(t.TempDir(), "sasldb2")
|
|
s := NewSASLDB(db, "mail.example.com")
|
|
s.run = func(args []string, stdin []byte) error {
|
|
if _, err := os.Stat(args[3]); !errors.Is(err, os.ErrNotExist) {
|
|
t.Errorf("saslpasswd2 given an existing file %s: %v", args[3], err)
|
|
}
|
|
return os.WriteFile(args[3], []byte("created"), 0o660)
|
|
}
|
|
if err := s.Set("alerts", "pw"); err != nil {
|
|
t.Fatalf("Set: %v", err)
|
|
}
|
|
if got, _ := os.ReadFile(db); string(got) != "created" {
|
|
t.Fatalf("database: %q", got)
|
|
}
|
|
}
|