Introduce Client wrapper with socket deadline support Add reconnection logic for body read timeouts Implement test cases for underflow scenarios Update orchestrator to handle reconnections
219 lines
6.2 KiB
Go
219 lines
6.2 KiB
Go
package imapx
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/emersion/go-imap/v2"
|
|
)
|
|
|
|
// underflowServer is a minimal IMAP server that reproduces the amega.kz bug: it
|
|
// answers a BODY[] FETCH by announcing a literal LARGER than the bytes it then
|
|
// sends, and afterwards goes silent — exactly what makes go-imap's strict
|
|
// literal reader block forever. LOGIN/EXAMINE and the Pass-1 metadata FETCH are
|
|
// answered normally so a full CopyFolder can reach the poisoned message.
|
|
//
|
|
// It serves one connection per accept and keeps accepting, so a reconnect gets
|
|
// a fresh, well-behaved session (its second EXAMINE reports zero messages, so
|
|
// the resumed folder simply finishes).
|
|
type underflowServer struct {
|
|
ln net.Listener
|
|
mu sync.Mutex
|
|
accepts int // how many connections have been accepted
|
|
stop chan struct{}
|
|
}
|
|
|
|
func newUnderflowServer(t *testing.T) *underflowServer {
|
|
t.Helper()
|
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatalf("listen: %v", err)
|
|
}
|
|
s := &underflowServer{ln: ln, stop: make(chan struct{})}
|
|
go s.serve()
|
|
return s
|
|
}
|
|
|
|
func (s *underflowServer) addr() Endpoint {
|
|
a := s.ln.Addr().(*net.TCPAddr)
|
|
return Endpoint{Host: "127.0.0.1", Port: a.Port, TLSMode: "plain"}
|
|
}
|
|
|
|
func (s *underflowServer) close() { close(s.stop); _ = s.ln.Close() }
|
|
|
|
func (s *underflowServer) serve() {
|
|
for {
|
|
conn, err := s.ln.Accept()
|
|
if err != nil {
|
|
return
|
|
}
|
|
s.mu.Lock()
|
|
s.accepts++
|
|
first := s.accepts == 1
|
|
s.mu.Unlock()
|
|
go s.handle(conn, first)
|
|
}
|
|
}
|
|
|
|
// handle drives one connection. On the FIRST connection the mailbox reports one
|
|
// message and its BODY[] fetch under-delivers; on any later connection (i.e.
|
|
// after a reconnect) the mailbox is empty so the resumed folder completes.
|
|
func (s *underflowServer) handle(conn net.Conn, first bool) {
|
|
defer func() { _ = conn.Close() }()
|
|
br := bufio.NewReader(conn)
|
|
fmt.Fprint(conn, "* OK IMAP4rev1 ready\r\n")
|
|
for {
|
|
line, err := br.ReadString('\n')
|
|
if err != nil {
|
|
return
|
|
}
|
|
fields := strings.Fields(line)
|
|
if len(fields) == 0 {
|
|
continue
|
|
}
|
|
tag := fields[0]
|
|
up := strings.ToUpper(line)
|
|
switch {
|
|
case strings.Contains(up, "LOGIN"):
|
|
fmt.Fprintf(conn, "%s OK LOGIN completed\r\n", tag)
|
|
case strings.Contains(up, "EXAMINE"), strings.Contains(up, "SELECT"):
|
|
n := 0
|
|
if first {
|
|
n = 1
|
|
}
|
|
fmt.Fprintf(conn, "* %d EXISTS\r\n", n)
|
|
fmt.Fprint(conn, "* OK [UIDVALIDITY 1] ok\r\n")
|
|
fmt.Fprintf(conn, "%s OK [READ-ONLY] EXAMINE completed\r\n", tag)
|
|
case strings.Contains(up, "BODY["), strings.Contains(up, "BODY.PEEK"):
|
|
// Poison: announce 100000 bytes, send 10, then stall until shutdown.
|
|
fmt.Fprint(conn, "* 1 FETCH (UID 1 BODY[] {100000}\r\n")
|
|
fmt.Fprint(conn, "0123456789")
|
|
<-s.stop
|
|
return
|
|
case strings.Contains(up, "FETCH"):
|
|
// Pass-1 metadata fetch for the single message.
|
|
fmt.Fprint(conn, "* 1 FETCH (UID 1 RFC822.SIZE 100 FLAGS () "+
|
|
"INTERNALDATE \"01-Jan-2020 00:00:00 +0000\" "+
|
|
"ENVELOPE (\"Wed, 01 Jan 2020 00:00:00 +0000\" \"poison\" NIL NIL NIL NIL NIL NIL NIL \"<poison@x>\"))\r\n")
|
|
fmt.Fprintf(conn, "%s OK FETCH completed\r\n", tag)
|
|
case strings.Contains(up, "LOGOUT"):
|
|
fmt.Fprintf(conn, "* BYE\r\n%s OK LOGOUT completed\r\n", tag)
|
|
return
|
|
case strings.Contains(up, "CREATE"), strings.Contains(up, "NOOP"):
|
|
fmt.Fprintf(conn, "%s OK completed\r\n", tag)
|
|
default:
|
|
fmt.Fprintf(conn, "%s OK completed\r\n", tag)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestStreamOneTimesOutOnLiteralUnderflow proves a server that announces a
|
|
// larger BODY[] literal than it sends makes streamOne return ErrBodyTimeout
|
|
// (bounded by bodyIdleTimeout) instead of hanging forever.
|
|
func TestStreamOneTimesOutOnLiteralUnderflow(t *testing.T) {
|
|
restore := shortenBodyIdle(200 * time.Millisecond)
|
|
defer restore()
|
|
|
|
srv := newUnderflowServer(t)
|
|
defer srv.close()
|
|
ctx := context.Background()
|
|
|
|
src, err := Connect(ctx, srv.addr())
|
|
if err != nil {
|
|
t.Fatalf("connect: %v", err)
|
|
}
|
|
defer func() { _ = src.Close() }()
|
|
if err := src.Login("u", "p").Wait(); err != nil {
|
|
t.Fatalf("login: %v", err)
|
|
}
|
|
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
done <- streamOne(src, src, "INBOX", imap.UID(1), nil, time.Time{}, nil)
|
|
}()
|
|
|
|
select {
|
|
case err := <-done:
|
|
if !errors.Is(err, ErrBodyTimeout) {
|
|
t.Fatalf("want ErrBodyTimeout, got %v", err)
|
|
}
|
|
case <-time.After(5 * time.Second):
|
|
t.Fatal("streamOne hung on literal underflow instead of timing out")
|
|
}
|
|
}
|
|
|
|
// TestCopyFolderSkipsAndReconnectsOnUnderflow proves CopyFolder does not hang on
|
|
// a poisoned message: it marks the message migrated (so future runs skip it),
|
|
// invokes ReconnectSrc, and returns with the error counted — the folder is not
|
|
// wedged.
|
|
func TestCopyFolderSkipsAndReconnectsOnUnderflow(t *testing.T) {
|
|
restore := shortenBodyIdle(200 * time.Millisecond)
|
|
defer restore()
|
|
|
|
srv := newUnderflowServer(t)
|
|
defer srv.close()
|
|
ctx := context.Background()
|
|
|
|
src, err := Connect(ctx, srv.addr())
|
|
if err != nil {
|
|
t.Fatalf("connect: %v", err)
|
|
}
|
|
defer func() { _ = src.Close() }()
|
|
if err := src.Login("u", "p").Wait(); err != nil {
|
|
t.Fatalf("login: %v", err)
|
|
}
|
|
|
|
var marked []string
|
|
var reconnected bool
|
|
deps := CopyDeps{
|
|
IsMigrated: func(string) (bool, error) { return false, nil },
|
|
MarkMigrated: func(_, k string) error { marked = append(marked, k); return nil },
|
|
OnProgress: func(_, _ int) {},
|
|
ReconnectSrc: func() (*Client, error) {
|
|
reconnected = true
|
|
nc, derr := Connect(ctx, srv.addr())
|
|
if derr != nil {
|
|
return nil, derr
|
|
}
|
|
if lerr := nc.Login("u", "p").Wait(); lerr != nil {
|
|
return nil, lerr
|
|
}
|
|
return nc, nil
|
|
},
|
|
}
|
|
|
|
done := make(chan CopyResult, 1)
|
|
go func() {
|
|
r, _ := CopyFolder(ctx, src, src, "INBOX", "INBOX", deps)
|
|
done <- r
|
|
}()
|
|
|
|
select {
|
|
case r := <-done:
|
|
if r.Errors == 0 {
|
|
t.Fatalf("expected the poisoned message counted as an error, got %+v", r)
|
|
}
|
|
if !reconnected {
|
|
t.Fatal("ReconnectSrc was never called")
|
|
}
|
|
if len(marked) != 1 {
|
|
t.Fatalf("poisoned message should be marked migrated once, got %v", marked)
|
|
}
|
|
case <-time.After(8 * time.Second):
|
|
t.Fatal("CopyFolder hung on a poisoned message instead of skipping it")
|
|
}
|
|
}
|
|
|
|
func shortenBodyIdle(d time.Duration) func() {
|
|
old := bodyIdleTimeout
|
|
bodyIdleTimeout = d
|
|
return func() { bodyIdleTimeout = old }
|
|
}
|