Attached please find a perl script which tests the CATENATE support in dovecot. I used this to test my CATENATE implementation a few years ago and it runs fine against dovecot in OS X Server. When run against dovecot-2.2.4 though it always fails or hangs, which in some cases means we interpreted RFCs differently and in other cases means it's finding bugs; both conditions are worthy of scrutiny. (It's random-number driven so every run is different.) A couple months ago I reported a few simple bugs which this script found and you fixed them; thanks. Then it started finding problems for which it's harder to isolate simple reproducible test cases. Vacations and other work interceded but now Apple is pleased to give you the script itself to allow you to iterate faster.
Here are some examples of it running. One time only, pass the --init argument to store some template messages used by the real tests: $ ./catenate.pl --host your.test.server --user testuser --password 1234 --init connecting (imaps)... capability... logging in... deleting old templates mailbox... creating templates mailbox... append1... Append succeeded append2... Append succeeded logout... success $ ./catenate.pl --host your.test.server --user testuser --password 1234 connecting (imaps)... capability... logging in... append1... Append failed as it should have (bad url): <append1 NO [BADURL ;invalid;] Invalid IMAP URL: Unexpected IMAP URL path segment: `;invalid;'.> append2... Append failed as it should have (bad url): <append2 NO [BADURL /inbox/;uid=99999999] Message not found.> append3... Append succeeded [...] All tests passed. $ If it concludes with anything other than "All tests passed" or hangs then it found something that it didn't expect and that should be examined. Use the --verbose option to see the entire client-server conversation. We hope that you find this script helpful to harden your CATENATE code. I will be happy to answer any questions.
#!/usr/bin/perl # Test CATENATE support in dovecot. # Use --init to initialize the template files in the mail store. # Copyright (c) 2013 Apple Inc. All Rights Reserved. # # @APPLE_LICENSE_HEADER_START@ # # This file contains Original Code and/or Modifications of Original Code # as defined in and that are subject to the Apple Public Source License # Version 2.0 (the 'License'). You may not use this file except in # compliance with the License. Please obtain a copy of the License at # http://www.opensource.apple.com/apsl/ and read it before using this # file. # # The Original Code and all software distributed under the License are # distributed on an 'AS IS' basis, WITHOUT WARRANTY OF ANY KIND, EITHER # EXPRESS OR IMPLIED, AND APPLE HEREBY DISCLAIMS ALL SUCH WARRANTIES, # INCLUDING WITHOUT LIMITATION, ANY WARRANTIES OF MERCHANTABILITY, FITNESS # FOR A PARTICULAR PURPOSE, QUIET ENJOYMENT OR NON-INFRINGEMENT. Please # see the License for the specific language governing rights and # limitations under the License. # # @APPLE_LICENSE_HEADER_END@ use strict; use IO::Socket::INET; use Getopt::Long; use IPC::Open3; use Digest::HMAC_MD5; use APR::Base64; use feature 'state'; sub usage { die <<EOT; Usage: $0 --host imap-server --user name --password pw Options: --appends n number of APPENDs --bufsiz n output buffer size --buftag tag output buffer flushes --init initialize template messages --mailbox box append messages to this mailbox --messages n messages per MULTIAPPEND --parts n parts per message --quiet --select box select this mailbox before appending --select-size n virtual size of message with UID=1 in selected mailbox --store path path to mail store for the user --verbose EOT } my %opts; GetOptions(\%opts, 'appends=i', 'bufsiz=i', 'buftag', 'host=s', 'init', 'mailbox=s', 'messages=i', 'parts=i', 'password=s', 'quiet', 'select=s', 'select-size=i', 'store=s', 'user=s', 'verbose', ) || usage(); $opts{appends} = 1000 unless defined($opts{appends}); usage() unless $opts{host}; $opts{mailbox} = "INBOX" unless defined($opts{mailbox}); usage() unless $opts{user}; usage() unless $opts{password}; if (defined($opts{select}) && !defined($opts{'select-size'})) { print STDERR "--select needs --select-size\n"; usage(); } my $checksizes = 0; if (defined($opts{store})) { my $myhost = `hostname`; chomp $myhost; if ($opts{host} eq 'localhost' or $opts{host} eq $myhost) { $checksizes = 1; } else { die "must run on server to check sizes\n"; } } $| = 1; # These must match the constants in cmd-append.c (dovecot < 2.2). # Change both copies (cmd-append.c and here) for better testing coverage. # Not sure if these apply to dovecot >= 2.2. my $MAX_URL_LITERAL_SIZE = 2048; # for testing: 20 my $MAX_CATENATE_MSG_SIZE = 4294967295; # for testing: 8000000 my $MAX_CATENATE_PARTS = 50; # for testing: 6 my $smallbody =<<'EOT'; Subject: small test message Date: Thu, 03 Sep 2009 21:37:10 -0500 (CDT) From: <user1@my_relay_test_domain.test> To: <us...@server.catenate.test> Message-Id: <200909032137100823.172md14mdtoow@gl088116> Content-type: text/plain user1 EOT $smallbody .= gentext(1007); $smallbody =~ s/\n/\r\n/g; my $largebody = largebody(); $largebody =~ s/\n/\r\n/g; my %template_urls = ("/templates/;uid=1" => length($smallbody), "/templates/;uid=2" => length($largebody)); my $imappid; local $SIG{__DIE__} = sub { kill(9, $imappid) if defined $imappid; }; my $reply; # try connecting via imaps, imap + starttls, imap, in that order my $secure = 0; my ($to_imap, $from_imap); print "connecting (imaps)...\n" unless $opts{quiet}; my @imapargv = ("/usr/bin/openssl", "s_client", "-connect", "$opts{host}:imaps"); push @imapargv, "-quiet" unless $opts{verbose}; $imappid = open3(\*TO_IMAP, \*FROM_IMAP, \*FROM_IMAP, @imapargv); sub openssl_happy_or_clean_up { my $label = shift; if (!defined($imappid)) { print "$label: couldn't run openssl: $!\n" if $opts{verbose}; } else { while ($reply = <FROM_IMAP>) { print "<OPENSSL< $reply" if $opts{verbose}; $reply =~ s/[\r\n]+$//; return 1 if $reply =~ /^\S+ OK /; if ($reply =~ /^connect:/i || $reply =~ /errno/) { print "$label: $reply\n" if $opts{verbose}; last; } } if (!defined($reply)) { print "$label: EOF\n" if $opts{verbose}; } } close(TO_IMAP); close(FROM_IMAP); if (defined($imappid)) { kill(9, $imappid); waitpid($imappid, 0); undef $imappid; } return 0; } if (openssl_happy_or_clean_up("$opts{host}:imaps")) { $to_imap = IO::Handle->new_from_fd(*TO_IMAP, "w"); $from_imap = IO::Handle->new_from_fd(*FROM_IMAP, "r"); if (!defined($to_imap) || !defined($from_imap)) { die "IO::Handle.new_from_fd: $!\n"; } $secure = 1; } else { print "connecting (imap + starttls)...\n" unless $opts{quiet}; @imapargv = ("/usr/bin/openssl", "s_client", "-connect", "$opts{host}:imap", "-starttls", "imap"); push @imapargv, "-quiet" unless $opts{verbose}; $imappid = open3(\*TO_IMAP, \*FROM_IMAP, \*FROM_IMAP, @imapargv); if (openssl_happy_or_clean_up("$opts{host}:imap + starttls")) { $to_imap = IO::Handle->new_from_fd(*TO_IMAP, "w"); $from_imap = IO::Handle->new_from_fd(*FROM_IMAP, "r"); if (!defined($to_imap) || !defined($from_imap)) { die "IO::Handle.new_from_fd: $!\n"; } $secure = 1; } else { print "connecting (imap)...\n" unless $opts{quiet}; $to_imap = IO::Socket::INET->new( PeerAddr => $opts{host}, PeerPort => 'imap(143)', Proto => 'tcp', Type => SOCK_STREAM, Timeout => 30, ); $from_imap = $to_imap; if (!defined($to_imap) || !defined($from_imap)) { die "IO::Socket::INET.new: $!\n"; } $reply = $from_imap->getline(); die "I/O error\n" if $from_imap->error; printS($reply) if $opts{verbose}; $reply =~ s/[\r\n]+$//; if ($reply !~ /\* OK/) { die "Bad greeting: <$reply>\n"; } } } $to_imap->autoflush(1); print "capability...\n" unless $opts{quiet}; send_data("c capability\r\n"); flush(); my $imap_auth_plain = 0; my $imap_auth_cram_md5 = 0; my $imap_auth_x_plain_submit = 0; while ($reply = $from_imap->getline()) { printS($reply) if $opts{verbose}; $reply =~ s/[\r\n]+$//; if ($reply =~ /^c /) { if ($reply !~ /c OK (\[.*\])?/) { die "Capability failed: <$reply>\n"; } last; } $imap_auth_plain = 1 if $reply =~ /CAPABILITY.*AUTH=PLAIN/i; $imap_auth_cram_md5 = 1 if $reply =~ /CAPABILITY.*AUTH=CRAM-MD5/i; $imap_auth_x_plain_submit = 1 if $reply =~ /CAPABILITY.*AUTH=X-PLAIN-SUBMIT/i; } die "I/O error\n" if $from_imap->error; if (!$imap_auth_plain && !$imap_auth_cram_md5) { die "$opts{host} supports neither PLAIN nor CRAM-MD5 auth so I don't know how to log in.\n"; } print "logging in...\n" unless $opts{quiet}; my $imap_auth = $imap_auth_cram_md5 ? "CRAM-MD5" : "PLAIN"; send_data("a authenticate $imap_auth\r\n"); flush(); $reply = $from_imap->getline(); die "I/O error\n" if $from_imap->error; printS($reply) if $opts{verbose}; $reply =~ s/[\r\n]+$//; if ($reply !~ /^\+/) { die "Authenticate failed: <$reply>\n"; } if ($imap_auth_cram_md5) { my ($challenge) = ($reply =~ /^\+ (.*)/); $challenge = APR::Base64::decode($challenge); print "Decoded challenge: $challenge\n" if $opts{verbose}; my $digest = Digest::HMAC_MD5::hmac_md5_hex($challenge, $opts{password}); $imap_auth = APR::Base64::encode("$opts{user} $digest"); } else { $imap_auth = APR::Base64::encode("\0$opts{user}\0$opts{password}"); } $imap_auth .= "\r\n"; send_data($imap_auth); flush(); my $capability; while ($reply = $from_imap->getline()) { printS($reply) if $opts{verbose}; $reply =~ s/[\r\n]+$//; if ($reply =~ /^a /) { if ($reply !~ /a OK /) { die "Login failed: <$reply>\n"; } $capability = $reply unless defined $capability; last; } elsif ($reply =~ /CAPABILITY/) { $capability = $reply; } } die "I/O error\n" if $from_imap->error; die "No CATENATE advertised in capability: <$capability>\n" unless $capability =~ /\WCATENATE(\W|$)/; my $expect_OK; my $explanation; if ($opts{init}) { print "deleting old templates mailbox...\n" unless $opts{quiet}; send_data("d delete templates\r\n"); flush(); while ($reply = $from_imap->getline()) { printS($reply) if $opts{verbose}; $reply =~ s/[\r\n]+$//; if ($reply =~ /^d /) { last; } } die "I/O error\n" if $from_imap->error; print "creating templates mailbox...\n" unless $opts{quiet}; send_data("c create templates\r\n"); flush(); while ($reply = $from_imap->getline()) { printS($reply) if $opts{verbose}; $reply =~ s/[\r\n]+$//; if ($reply =~ /^c /) { if ($reply !~ /^c OK/) { die "Create failed: <$reply>\n"; } last; } } die "I/O error\n" if $from_imap->error; $opts{mailbox} = "templates"; $opts{messages} = 1; $opts{parts} = -1; $opts{text} = $smallbody; $expect_OK = 1; append(1); die if !$expect_OK; $opts{text} = $largebody; $expect_OK = 1; append(2); die if !$expect_OK; print "logout...\n" unless $opts{quiet}; send_data("z logout\r\n"); flush(); while ($reply = $from_imap->getline()) { printS($reply) if $opts{verbose}; $reply =~ s/[\r\n]+$//; if ($reply =~ /^z /) { if ($reply !~ /z OK (\[.*\])?/) { die "Logout failed: <$reply>\n"; } last; } } die "I/O error\n" if $from_imap->error; $to_imap->close(); if (defined($imappid)) { $from_imap->close(); waitpid($imappid, 0); undef $imappid; } print "success\n" unless $opts{quiet}; exit 0; } if (defined($opts{select})) { print "selecting $opts{select}...\n" unless $opts{quiet}; send_data("b select $opts{select}\r\n"); flush(); while ($reply = $from_imap->getline()) { printS($reply) if $opts{verbose}; $reply =~ s/[\r\n]+$//; if ($reply =~ /^b /) { if ($reply !~ /b OK (\[.*\] )?Select completed/) { die "Select failed: <$reply>\n"; } last; } } die "I/O error\n" if $from_imap->error; } my $ok = 1; for my $append (1..$opts{appends}) { $expect_OK = 1; undef $explanation; if (append($append) < 0) { $ok = 0; last; } } my $catch_up = 0; # with openssl, dies from SIGPIPE if ($catch_up) { print "catching up with server output...\n" unless $opts{quiet}; eval { local $SIG{ALRM} = sub { die "alarm\n"; }; my $cruft = 0; alarm(1); while ($reply = $from_imap->getline()) { printS($reply); $cruft = 1; alarm(1); } alarm(0); die "cruft\n" if $cruft; }; if ($ok && $@ =~ /cruft/) { die "Unexpected server output.\n"; } } print "sending noop...\n" unless $opts{quiet}; send_data("y noop\r\n"); flush(); while ($reply = $from_imap->getline()) { my $orig_reply = $reply; printS($reply) if $opts{verbose}; $reply =~ s/[\r\n]+$//; if ($reply =~ /^y /) { if ($reply =~ /^y OK/) { print "Noop succeeded.\n" unless $opts{quiet}; } else { die "Noop failed: <$reply>\n"; } last; } elsif (!$catch_up) { printS($orig_reply); } } die "I/O error\n" if $from_imap->error; print "logout...\n" unless $opts{quiet}; send_data("z logout\r\n"); flush(); while ($reply = $from_imap->getline()) { printS($reply) if $opts{verbose}; $reply =~ s/[\r\n]+$//; if ($reply =~ /^z /) { if ($reply !~ /z OK (\[.*\])?/) { die "Logout failed: <$reply>\n"; } last; } } die "I/O error\n" if $from_imap->error; $to_imap->close(); if (defined($imappid)) { $from_imap->close(); waitpid($imappid, 0); undef $imappid; } if ($ok) { print "All tests passed.\n"; exit 0; } else { print "At least one test failed.\n"; exit 1; } sub append { my $append = shift; my $tag = "append$append"; print "$tag...\n" unless $opts{quiet}; send_data("$tag APPEND $opts{mailbox}"); my @sizes; my $multi = $opts{messages}; $multi = int(rand(4)) + 1 unless defined $multi; for (1..$multi) { my $size = 0; my $ret = message($tag, \$size); return $ret if $ret <= 0; # aborted message, but keep going last if $ret == 2; push @sizes, $size; # RFC 3502 & 4466 say there's no space or newline here } send_data("\r\n"); flush(); while ($reply = $from_imap->getline()) { printS($reply) if $opts{verbose}; $reply =~ s/[\r\n]+$//; if ($reply =~ /^$tag /) { if ($expect_OK) { if ($reply =~ /$tag OK /) { print "Append succeeded\n" unless $opts{quiet}; } else { print STDERR "Append failed but should have succeeded: <$reply>\n"; if ($reply =~ /can.t open mailbox/i || $reply =~ /mailbox does.?n.t exist/i) { print STDERR "Maybe you forgot to --init?\n"; } return -1; } } else { if ($reply =~ /$tag OK /) { print STDERR "Append succeeded but should have failed ($explanation): <$reply>\n"; return -1; } else { print "Append failed as it should have ($explanation): <$reply>\n" unless $opts{quiet}; return 0; } } last; } } die "I/O error\n" if $from_imap->error; if ($checksizes) { print "checking sizes...\n" unless $opts{quiet}; my $dir = ""; $dir = "/.$opts{mailbox}" unless $opts{mailbox} =~ /^inbox$/i; my $count = @sizes; my @messages = `ls -l $opts{store}$dir/new | tail -$count`; for (0..$#sizes) { if (!defined($messages[$_])) { die "message is missing\n"; } my @fields = split(/\s+/, $messages[$_]); if ($fields[4] != $sizes[$_]) { die "$fields[$#fields]: message size $fields[4], expected $sizes[$_]\n"; } elsif (!$opts{quiet}) { print "$fields[$#fields]: size $fields[4] OK\n"; } } } return 1; } sub message { my $tag = shift; my $size_ref = shift; if (int(rand(2)) == 0) { my $flaglist = " ("; for (1..int(rand(3) + 1)) { my @flags = ("\\Answered", "\\Draft", "\\Flagged", "foobar"); $flaglist .= $flags[int(rand(@flags))] . " "; } $flaglist =~ s/ $/)/; send_data($flaglist); } if (int(rand(2)) == 0) { my ($sec,$min,$hour,$mday,$mon,$year,$wday,$yday,$isdst) = localtime(time); my @months = qw(Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec); my $datetime = sprintf(" \"%2d-%s-%d %02d:%02d:%02d -0600\"", $mday, $months[$mon], $year+1900, $hour, $min, $sec); send_data($datetime); } send_data(" "); my $parts = $opts{parts}; if (!defined($parts)) { $parts = int(rand(10)) - 1; if (int(rand(50)) == 0) { $parts = $MAX_CATENATE_PARTS + 5; } } if ($parts == -1) { my @texts = ($smallbody, $largebody, ""); my $text = $texts[int(rand(@texts))]; if (defined($opts{text})) { # init $text = $opts{text}; } elsif (length($text) > 0) { $text = "X-Catenate-Append-Tag: $tag\r\n$text"; } $$size_ref += length($text); failif(length($text) == 0, "empty message"); my $r = int(rand(2)); if ($r == 0) { send_nonsync_literal($text); } else { my $ret = send_sync_literal($text); return $ret if $ret <= 0; } } else { failif($parts == 0, "0 parts"); send_data("CATENATE ("); $$size_ref = 0; for (1..$parts) { failif($_ > $MAX_CATENATE_PARTS, "too many parts"); my $ret = part($tag, $size_ref); return $ret if $ret <= 0; send_data(" ") if $_ < $parts; } send_data(")"); # dovecot < 2.2 (which offers X-PLAIN-SUBMIT auth) should reject empty messages # dovecot >= 2.2 (which doesn't) doesn't failif($imap_auth_x_plain_submit && $$size_ref == 0, "empty message"); # 0 parts is invalid syntax, so don't MULTIAPPEND after it return 2 if $parts == 0; } return 1; } sub part { my $tag = shift; my $size_ref = shift; if (int(rand(2)) == 0) { my @good_urls = keys %template_urls; my @bad_urls = ("/nonexistent-folder-and-too-long-as-a-literal/;uid=1", "/inbox/;uid=99999999", ";invalid;"); my @select_urls = ("/;uid=1"); if (defined($opts{select})) { push @good_urls, @select_urls; } else { push @bad_urls, @select_urls; } my @urls = (@good_urls, @bad_urls); my $url = $urls[int(rand(@urls))]; my $bad = grep {$_ eq $url} @bad_urls; if (!$bad) { if (grep {$_ eq $url} @select_urls) { $$size_ref += $opts{'select-size'}; } else { $$size_ref += $template_urls{$url}; } } send_data("URL "); my $r = int(rand(4)); if ($r == 0) { send_data($url); } elsif ($r == 1) { send_data("\"$url\""); } elsif ($r == 2) { failif(length($url) > $MAX_URL_LITERAL_SIZE, "url literal too large"); send_nonsync_literal($url); } else { failif(length($url) > $MAX_URL_LITERAL_SIZE, "url literal too large"); my $ret = send_sync_literal($url); return $ret if $ret <= 0; } failif($bad, "bad url"); failif($$size_ref > $MAX_CATENATE_MSG_SIZE, "message too large"); } else { my @texts = ($smallbody, $largebody, ""); my $text = $texts[int(rand(@texts))]; if (length($text) > 0) { $text = "X-Catenate-Append-Tag: $tag\r\n$text"; } $$size_ref += length($text); failif($$size_ref > $MAX_CATENATE_MSG_SIZE, "message too large"); send_data("TEXT "); my $r = int(rand(2)); if ($r == 0) { send_nonsync_literal($text); } else { my $ret = send_sync_literal($text); return $ret if $ret <= 0; } } return 1; } sub flush { send_data(undef); } sub send_data { my $data = shift; state $bufsiz = undef; state $buf = ""; my $flush; if (defined($data)) { if (!defined($bufsiz)) { $bufsiz = $opts{bufsiz}; if (!defined($bufsiz)) { my $r = int(rand(3)); if ($r == 0) { $bufsiz = 0; } elsif ($r == 1) { $bufsiz = int(rand(64)) + 1; } else { $bufsiz = int(rand(4096)) + 1; } } } $buf .= $data; $flush = length($buf) >= $bufsiz; } else { $flush = 1; } if ($flush && length($buf)) { printC($buf) if $opts{verbose}; $to_imap->print($buf); undef $bufsiz; $buf = ""; } } sub send_nonsync_literal { my $literal = shift; my $len = length($literal); send_data("{$len+}\r\n$literal"); } sub send_sync_literal { my $literal = shift; my $len = length($literal); send_data("{$len}\r\n"); flush(); while ($reply = $from_imap->getline()) { printS($reply) if $opts{verbose}; $reply =~ s/[\r\n]+$//; if ($expect_OK) { if ($reply !~ /^\+/) { print STDERR "Append failed but should have succeeded: <$reply>\n"; if ($reply =~ /can.t open mailbox/i || $reply =~ /mailbox does.?n.t exist/i) { print STDERR "Maybe you forgot to --init?\n"; } return -1; } } else { if ($reply =~ /^\+/) { print STDERR "Append succeeded but should have failed ($explanation): <$reply>\n"; exit 1; } else { print "Append failed as it should have ($explanation): <$reply>\n" unless $opts{quiet}; return 0; } } last; } die "I/O error\n" if $from_imap->error; send_data($literal); return 1; } sub printC { my $msg = shift; printX("C", $msg); print "~FLUSH~" if $opts{buftag}; } sub printS { printX("S", @_); } sub printX { my $tag = shift; my $msg = shift; state $lastdir = ""; state $lastmsg = "\n"; if ($tag eq "C") { if ($lastdir ne "C") { print "~NO LINE TERMINATOR~\n" if $lastmsg !~ /\n$/; print ">"x72 . "\n"; $lastdir = "C"; } } else { if ($lastdir ne "S") { print "~NO LINE TERMINATOR~\n" if $lastmsg !~ /\n$/; print "<"x72 . "\n"; $lastdir = "S"; } } print $msg; $lastmsg = $msg; } sub failif { my $what = shift; my $why = shift; if ($what && $expect_OK) { $expect_OK = 0; $explanation = $why; } } sub gentext { my $size = shift; my $text = ""; while (length($text) < $size) { for (1..7) { for (1..9) { $text .= chr(int(rand(26)) + 97); # 'a' - 'z' } $text .= " "; } $text .= "\n"; } return substr($text, 0, $size) . "\n"; } sub largebody { my $body =<<'EOT'; Subject: large test message Date: Mon, 11 Jan 2010 08:29:05 -0600 (CST) From: <user34@my_relay_test_domain.test> To: <use...@server.catenate.test> Message-Id: <201001110829050698.xbbg761r13pe@server> Mime-Version: 1.0 Content-Type: multipart/mixed; boundary="yfyadhxohiczseo" user18 EOT for (2062200, 4028, 504, 1031100, 8056) { $body .=<<'EOT'; --yfyadhxohiczseo Content-Type: text/plain; charset="US-ASCII" Content-Transfer-Encoding: quoted-printable EOT $body .= gentext($_); } $body .=<<'EOT'; --yfyadhxohiczseo-- EOT # 43784 lines return $body; }