Files
selfpost/internal/postfix/queue_test.go
T
mixandClaude Opus 5.5 60c7b85f84
test / test (push) Canceled after 0s
findings of the reviews of audit stage 1
Technical review (Opus) and security review (Fable) of 9efc9c7..a59e3d4;
reports in the session's scratchpad. Fixed:

- B1 (verified bug): store.UpdateStatus read before writing in its
  transaction; a commit by another connection in between (the dmarc-ingest
  process opens the same file) failed it at once with "database is locked"
  and the delivery line was lost. The first statement is now a write.
- R3: a delivery line updated every row of its queue id and recipient; it
  now updates only the newest (Postfix reuses short ids). The sweep closes
  by row id (QueuedDelivery.ID, CloseUnreported(id)).
- R4: a contradicted sweep keeps a mark, reason "reconcile-contradicted",
  instead of losing which row it was.
- R1: contradicted cannot see a lost line (never read late). New counter
  reconcile_missed: at close, the sweep looks for the row's delivery line
  in mail.log and mail.log.1 (QueueLines). 0007 edited - it has run on no
  database but throwaway stands.
- R2: queued_over_lifetime cannot measure "what removing the sweep would
  leave" while the sweep runs; its text now says rows stuck despite the
  sweep, and the rule uses closed - missed - contradicted.
- cert-reload (R5, R6, R7, security medium/low): both files copied to .new
  and the copies checked as a pair before both are renamed; acts on the copy
  only when postconf says Postfix reads it; a failed reload is retried next
  pass; umask 077; says so when the mounted files cannot be read. The e2e
  case asserts Postfix reads the copy, the copy equals the mount, the key is
  600 root.
- Security low: a recipient with a quoted local part - the journal cut
  "a b"@x at the space, deliveryRe cut "a>b"@x at the ">" - matched no
  delivery line and was closed by the sweep. cleanAddress ends a bracketed
  address at the first ">" outside quotes; deliveryRe takes the recipient
  up to the ">, " before relay= (test fixtures given the relay= every real
  delivery line has).
- Security low: postqueue -p's error carries at most 512 bytes of output.
- Nits: addDeliveryLogDay refuses a column not on its list; the truncation
  test checks its message.
- To stage 2b (page wording, mockup-first): the history step of a bounced
  row says the receiving server refused it, false for a row the sweep
  closed. To the owner when stage 2b is reached: since M13 the "deferred"
  figure counts whole messages too, which "Connections turned away" would
  not name.

Found while verifying: the e2e's main positive path failed once with "553
not owned by user app1" - an smtpd idle from before the panel's reload took
the connection in the reload's second. Measured on the stand (one-off
probe, not committed): sending 0, 10, 40, 110 s after the save (two seconds
at the soonest) always met an smtpd started after the reload. The e2e now
waits for the sender map after each application it creates
(waitForSenderMap: AUTH, MAIL, RCPT, RSET - no message, no row), and the
level-1 case uses it instead of its own loop.

Docs: architecture § Queue reconcile, schema-migrations (rows 7 and 8,
chain head 8), CHANGELOG, audit plan (stage 1 note, the measured window,
a new stage 2b line: 62 lines), roadmap 15/62.

Evidence on the test server for this tree: go vet clean, go test ./... 23
packages ok, make e2e exit 0 (renewed certificate, hung panel, level 1 at
49/60).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 15:21:52 +03:00

145 lines
5.0 KiB
Go

package postfix
import (
"bufio"
"context"
"fmt"
"os"
"os/exec"
"strings"
"testing"
)
// TestFakePostqueue is not a test: it is the fake `postqueue -p` the tests
// below run in place of the real one, writing SP_FAKE_POSTQUEUE_BYTES bytes of
// queue entries.
func TestFakePostqueue(t *testing.T) {
if os.Getenv("SP_FAKE_POSTQUEUE") != "1" {
t.Skip("helper process")
}
var size int
fmt.Sscan(os.Getenv("SP_FAKE_POSTQUEUE_BYTES"), &size)
out := bufio.NewWriter(os.Stdout)
fmt.Fprintln(out, "-Queue ID- --Size-- ----Arrival Time---- -Sender/Recipient-------")
for n, i := 0, 0; n < size; i++ {
entry := fmt.Sprintf("%010X 446 Fri Aug 8 10:13:31 app@example.com\n%41s rcpt@example.net\n\n", i, "")
out.WriteString(entry)
n += len(entry)
}
out.Flush()
os.Exit(0)
}
// fakePostqueue puts a postqueue that lists about size bytes of entries in
// place of the real one for the rest of the test.
func fakePostqueue(t *testing.T, size int) {
t.Helper()
old := postqueueP
postqueueP = func(ctx context.Context) *exec.Cmd {
cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestFakePostqueue$")
cmd.Env = append(os.Environ(), "SP_FAKE_POSTQUEUE=1", fmt.Sprintf("SP_FAKE_POSTQUEUE_BYTES=%d", size))
return cmd
}
t.Cleanup(func() { postqueueP = old })
}
// A listing over the cap is no listing for the reconcile sweep: the messages
// past the cut are still queued, and taking the first 4 MiB of ids for the
// whole queue closed every row past it as bounced (audit 2026-10-12, H3).
// The queue page still shows the cut listing, with the note.
func TestQueueIDsRefusesATruncatedListing(t *testing.T) {
fakePostqueue(t, 5<<20)
if ids, err := QueueIDs(); err == nil || !strings.Contains(err.Error(), "listing is larger than") {
t.Fatalf("QueueIDs on a 5 MiB listing = %d ids, %v; want the listing refused for its size", len(ids), err)
}
text, err := Queue()
if err != nil {
t.Fatalf("Queue on a 5 MiB listing: %v", err)
}
if !strings.HasSuffix(text, fmt.Sprintf("[listing truncated at %d bytes]\n", maxQueueOutput)) {
t.Fatalf("Queue on a 5 MiB listing does not end with the truncation note: ...%q", text[len(text)-80:])
}
}
func TestQueueIDsReadsAListingUnderTheCap(t *testing.T) {
fakePostqueue(t, 1000)
ids, err := QueueIDs()
if err != nil {
t.Fatal(err)
}
if _, ok := ids["0000000000"]; !ok || len(ids) < 5 {
t.Fatalf("QueueIDs on a small listing = %v", ids)
}
}
// The reconcile sweep decides whether a message is still Postfix's problem, so
// the parser must pick queue ids out of a real listing and nothing else out of
// it: not the header, not the byte-count trailer, and above all not a deferred
// entry's reason line, which — unlike the recipient lines — starts at the left
// margin just as an entry does.
func TestParseQueueIDs(t *testing.T) {
listing := `-Queue ID- --Size-- ----Arrival Time---- -Sender/Recipient-------
3C5B04E6C1* 446 Fri Aug 8 10:12:31 app@example.com
rcpt@example.net
5B4A2C1D3E 446 Fri Aug 8 10:13:31 app@example.com
(connect to mx.example.net[203.0.113.9]:25: Connection timed out)
deferred@example.net
A1B2C3D4E5F! 891 Fri Aug 8 10:14:31 app@example.com
held@example.net
-- 1 Kbytes in 3 Requests.
`
ids := parseQueueIDs(listing)
want := []string{"3C5B04E6C1", "5B4A2C1D3E", "A1B2C3D4E5F"}
for _, id := range want {
if _, ok := ids[id]; !ok {
t.Errorf("queue id %s not found in %v", id, ids)
}
}
if len(ids) != len(want) {
t.Errorf("got %d ids %v, want exactly %v", len(ids), ids, want)
}
}
// An empty queue must come back as an empty set, not as a phantom id parsed out
// of Postfix's prose — every stale row would otherwise be compared against a
// listing that claims to hold a message called "Mail".
func TestParseQueueIDsOnAnEmptyQueue(t *testing.T) {
if ids := parseQueueIDs("Mail queue is empty\n"); len(ids) != 0 {
t.Errorf("got %v, want no ids", ids)
}
}
// The queue listing is held in memory and rendered into a page, so a very large
// queue must not size the panel's allocation: the writer keeps the cap and
// reports the rest as dropped, while still accepting every write so postqueue
// itself is never cut short.
func TestLimitWriterCapsOutput(t *testing.T) {
w := &limitWriter{remaining: 8}
n, err := w.Write([]byte("12345"))
if n != 5 || err != nil {
t.Fatalf("first write = %d, %v", n, err)
}
if w.truncated {
t.Fatal("truncated before the cap was reached")
}
n, err = w.Write([]byte("67890"))
if n != 5 || err != nil {
t.Fatalf("second write = %d, %v", n, err)
}
if !w.truncated {
t.Fatal("write past the cap was not recorded as truncated")
}
if got := w.buf.String(); got != "12345678" {
t.Fatalf("kept %q, want the first 8 bytes", got)
}
if n, err := w.Write([]byte("more")); n != 4 || err != nil {
t.Fatalf("write after the cap = %d, %v (must still accept)", n, err)
}
if got := w.buf.String(); got != "12345678" {
t.Fatalf("buffer grew past the cap: %q", got)
}
}