#!/usr/bin/perl -w # Test Apple's BDAT/CHUNKING/BINARYMIME extension to postfix. # Not for distribution outside Apple. use strict; use IO::Socket::INET; use Getopt::Long; use IPC::Open3; use Digest::HMAC_MD5; use APR::Base64; use List::Util 'shuffle'; use MIME::QuotedPrint; use feature 'state'; sub usage { die < 0; usage() unless $opts{host}; if ($opts{host} !~ /\./) { print STDERR "Warning: --host $opts{host} is not fully-qualified and probably won't work.\n"; } usage() unless $opts{user}; usage() unless $opts{password}; $| = 1; my ($smtppid, $imappid); local $SIG{__DIE__} = sub { kill(9, $smtppid) if defined $smtppid; kill(9, $imappid) if defined $imappid; }; my $reply; my %typefuncs = ( "" => [\&header_plain, \&body_plain], "text/plain" => [\&header_plain, \&body_plain], "message/rfc822" => [\&header_message, \&body_message], "application/octet-stream" => [\&header_gibberish, \&body_gibberish], "multipart/mixed" => [\&header_mixed, \&body_mixed], ); my @types = keys %typefuncs; my @encodings_top = ("", "7bit", "8bit", "binary"); my @encodings_sub = (@encodings_top, "base64", "quoted-printable"); my %encodingfuncs = ( "" => \&clean_7bit, "7bit" => \&clean_7bit, "8bit" => \&clean_8bit, "binary" => \&clean_binary, "base64" => \&clean_base64, "quoted-printable" => \&clean_qp, ); my $top_encoding; if ($opts{test}) { my ($raw, $clean, $rawsections, $cleansections, $fetchable) = message("body=binarymime", "test"); my @rawsections = @$rawsections; my @cleansections = @$cleansections; my @fetchable = @$fetchable; print "=== RAW ===\n$raw". "\n=== CLEAN ===\n$clean". "\n=== RAW SECTIONS ===\n".join("//\n",@rawsections). "\n=== CLEAN SECTIONS ===\n".join("//\n",@cleansections). "\n=== FETCHABLE ===\n".join("//\n",@fetchable). "\n=== END ===\n"; my $sanity = ""; $sanity .= $_ for @rawsections; die "Internal consistency botch: sectioned message does not match whole.\nRaw:\n$raw\nSectioned:\n$sanity\n" unless $sanity eq $raw; $sanity = ""; $sanity .= $_ for @cleansections; die "Internal consistency botch: sectioned message does not match whole.\nClean:\n$clean\nSectioned:\n$sanity\n" unless $sanity eq $clean; open RAW, ">/tmp/chunking.raw" or die; print RAW $raw; close RAW; open CLEAN, ">/tmp/chunking.clean" or die; print CLEAN $clean; close CLEAN; open RAWSECTIONS, ">/tmp/chunking.rawsections" or die; print RAWSECTIONS join("//\n",@rawsections); close RAWSECTIONS; open CLEANSECTIONS, ">/tmp/chunking.cleansections" or die; print CLEANSECTIONS join("//\n",@cleansections); close CLEANSECTIONS; open FETCHABLE, ">/tmp/chunking.fetchable" or die; print FETCHABLE join("//\n",@fetchable); close FETCHABLE; #system("xdiff -a /tmp/chunking.raw /tmp/chunking.clean /tmp/chunking.cleansections"); exit 0; } # try connecting via imaps, imap + starttls, imap, in that order my ($to_imap, $from_imap); print "connecting (imaps)...\n" unless $opts{quiet}; my @imapargv = ("/usr/bin/openssl", "s_client", "-ign_eof", "-connect", "$opts{host}:imaps"); push @imapargv, "-quiet" unless $opts{verbose}; $imappid = open3(\*TO_IMAP, \*FROM_IMAP, \*FROM_IMAP, @imapargv); sub openssl_imap_happy_or_clean_up { my $label = shift or die; if (!defined($imappid)) { print "$label: couldn't run openssl: $!\n" if $opts{verbose}; } else { while ($reply = ) { print "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"; } } else { print "connecting (imap + starttls)...\n" unless $opts{quiet}; @imapargv = ("/usr/bin/openssl", "s_client", "-ign_eof", "-connect", "$opts{host}:imap", "-starttls", "imap"); push @imapargv, "-quiet" unless $opts{verbose}; $imappid = open3(\*TO_IMAP, \*FROM_IMAP, \*FROM_IMAP, @imapargv); if (openssl_imap_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"; } } 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; imap_printS($reply) if $opts{verbose}; $reply =~ s/[\r\n]+$//; if ($reply !~ /\* OK (\[.*\] )?Dovecot.* ready\./) { die "Bad greeting: <$reply>\n"; } } } $to_imap->autoflush(1); print "capability...\n" unless $opts{quiet}; imap_send_data("c capability\r\n"); imap_flush(); my $imap_auth_plain = 0; my $imap_auth_cram_md5 = 0; while ($reply = $from_imap->getline()) { imap_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; } 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"; imap_send_data("a authenticate $imap_auth\r\n"); imap_flush(); $reply = $from_imap->getline(); die "I/O error\n" if $from_imap->error; imap_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"; imap_send_data($imap_auth); imap_flush(); while ($reply = $from_imap->getline()) { imap_printS($reply) if $opts{verbose}; $reply =~ s/[\r\n]+$//; if ($reply =~ /^a /) { if ($reply !~ /a OK (\[.*\] )?Logged in/) { die "Login failed: <$reply>\n"; } last; } } die "I/O error\n" if $from_imap->error; print "create scratchbox...\n" unless $opts{quiet}; imap_send_data("b create scratchbox\r\n"); imap_flush(); while ($reply = $from_imap->getline()) { imap_printS($reply) if $opts{verbose}; $reply =~ s/[\r\n]+$//; if ($reply =~ /^b /) { last; } } die "I/O error\n" if $from_imap->error; print "select...\n" unless $opts{quiet}; imap_send_data("c select inbox\r\n"); imap_flush(); my $inbox_message_count; while ($reply = $from_imap->getline()) { imap_printS($reply) if $opts{verbose}; $reply =~ s/[\r\n]+$//; if ($reply =~ /^c OK /) { last; } elsif ($reply =~ /^\* (\d+) EXISTS/i) { $inbox_message_count = $1; } } die "I/O error\n" if $from_imap->error; print "idle...\n" unless $opts{quiet}; imap_send_data("i idle\r\n"); imap_flush(); my $imap_idle = 0; while ($reply = $from_imap->getline()) { imap_printS($reply) if $opts{verbose}; $reply =~ s/[\r\n]+$//; if ($reply =~ /^\+ /) { $imap_idle = 1; last; } elsif ($reply =~ /^i /) { die "Idle failed: <$reply>\n"; } } die "I/O error\n" if $from_imap->error; # try connecting via submission + starttls, smtp + starttls, smtp, in that order my $submission = 0; my ($to_smtp, $from_smtp); print "connecting (submission + starttls)...\n" unless $opts{quiet}; my @smtpargv = ("/usr/bin/openssl", "s_client", "-ign_eof", "-connect", "$opts{host}:submission", "-starttls", "smtp"); push @smtpargv, "-quiet" unless $opts{verbose}; $smtppid = open3(\*TO_SMTP, \*FROM_SMTP, \*FROM_SMTP, @smtpargv); sub openssl_smtp_happy_or_clean_up { my $label = shift or die; if (!defined($smtppid)) { print "$label: couldn't run openssl: $!\n" if $opts{verbose}; } else { while ($reply = ) { print "new_from_fd(*TO_SMTP, "w"); $from_smtp = IO::Handle->new_from_fd(*FROM_SMTP, "r"); if (!defined($to_smtp) || !defined($from_smtp)) { die "IO::Handle.new_from_fd: $!\n"; } $submission = 1; } else { print "connecting (smtp + starttls)...\n" unless $opts{quiet}; @smtpargv = ("/usr/bin/openssl", "s_client", "-ign_eof", "-connect", "$opts{host}:smtp", "-starttls", "smtp"); push @smtpargv, "-quiet" unless $opts{verbose}; $smtppid = open3(\*TO_SMTP, \*FROM_SMTP, \*FROM_SMTP, @smtpargv); if (openssl_smtp_happy_or_clean_up("$opts{host}:smtp + starttls")) { $to_smtp = IO::Handle->new_from_fd(*TO_SMTP, "w"); $from_smtp = IO::Handle->new_from_fd(*FROM_SMTP, "r"); if (!defined($to_smtp) || !defined($from_smtp)) { die "IO::Handle.new_from_fd: $!\n"; } } else { print "connecting (smtp)...\n" unless $opts{quiet}; $to_smtp = IO::Socket::INET->new( PeerAddr => $opts{host}, PeerPort => 'smtp(25)', Proto => 'tcp', Type => SOCK_STREAM, Timeout => 30, ); $from_smtp = $to_smtp; if (!defined($to_smtp) || !defined($from_smtp)) { die "IO::Socket::INET.new: $!\n"; } $reply = $from_smtp->getline(); die "I/O error\n" if $from_smtp->error; smtp_printS($reply) if $opts{verbose}; $reply =~ s/[\r\n]+$//; if ($reply !~ /^220 /) { die "Bad greeting: <$reply>\n"; } } } $to_smtp->autoflush(1); my $submit_burl = 0; if ($submission) { print "ehlo...\n" unless $opts{quiet}; smtp_send_data("ehlo bdat.pl\r\n"); smtp_flush(); my $submit_auth_plain = 0; my $submit_auth_cram_md5 = 0; while ($reply = $from_smtp->getline()) { smtp_printS($reply) if $opts{verbose}; $reply =~ s/[\r\n]+$//; $submit_auth_plain = 1 if $reply =~ /^\d+.AUTH.*PLAIN/i; $submit_auth_cram_md5 = 1 if $reply =~ /^\d+.AUTH.*CRAM-MD5/i; if ($reply =~ /^\d+.BURL/) { if ($reply !~ /^\d+.BURL$/) { die "Unexpected BURL arguments: <$reply>\n"; } $submit_burl = 1; } if ($reply =~ /^\d+ /) { if ($reply !~ /^2/) { die "Ehlo failed: <$reply>\n"; } last; } } die "I/O error\n" if $from_smtp->error; if (!$submit_auth_plain && !$submit_auth_cram_md5) { print STDERR "Submission server supports neither PLAIN nor CRAM-MD5 auth so I don't know how to log in.\n"; print STDERR "Continuing without BURL\n"; $submit_burl = 0; } elsif (!$submit_burl) { print STDERR "Submission server does not support BURL\n"; print STDERR "Continuing without BURL\n"; } else { print "logging in...\n" unless $opts{quiet}; my $submit_auth = $submit_auth_cram_md5 ? "CRAM-MD5" : "PLAIN"; smtp_send_data("auth $submit_auth\r\n"); smtp_flush(); while ($reply = $from_smtp->getline()) { smtp_printS($reply) if $opts{verbose}; $reply =~ s/[\r\n]+$//; if ($reply =~ /^\d+/) { if ($reply !~ /^3/) { die "Auth failed: <$reply>\n"; } last; } } die "I/O error\n" if $from_smtp->error; if ($submit_auth_cram_md5) { my ($challenge) = ($reply =~ /^\d+ (.*)/); $challenge = APR::Base64::decode($challenge); print "Decoded challenge: $challenge\n" if $opts{verbose}; my $digest = Digest::HMAC_MD5::hmac_md5_hex($challenge, $opts{password}); smtp_send_data(APR::Base64::encode("$opts{user} $digest") . "\r\n"); } else { smtp_send_data(APR::Base64::encode("\0$opts{user}\0$opts{password}") . "\r\n"); } smtp_flush(); while ($reply = $from_smtp->getline()) { smtp_printS($reply) if $opts{verbose}; $reply =~ s/[\r\n]+$//; if ($reply =~ /^\d+ /) { if ($reply !~ /^2/) { die "Auth failed: <$reply>\n"; } last; } } die "I/O error\n" if $from_smtp->error; } } print "ehlo...\n" unless $opts{quiet}; smtp_send_data("ehlo bdat.pl\r\n"); smtp_flush(); my $smtp_binarymime; my $smtp_chunking; my $smtp_burl_imap; while ($reply = $from_smtp->getline()) { smtp_printS($reply) if $opts{verbose}; $reply =~ s/[\r\n]+$//; $smtp_binarymime = 1 if $reply =~ /^250[- ]BINARYMIME$/; $smtp_chunking = 1 if $reply =~ /^250[- ]CHUNKING$/; $smtp_burl_imap = 1 if $reply =~ /^250[- ]BURL imap$/; if ($reply =~ /^\d+ /) { if ($reply !~ /250 /) { die "Ehlo failed: <$reply>\n"; } last; } } die "I/O error\n" if $from_imap->error; die "$opts{host} did not advertise BINARYMIME in ehlo reply\n" unless $smtp_binarymime; die "$opts{host} did not advertise CHUNKING in ehlo reply\n" unless $smtp_chunking; warn "$opts{host} did not advertise BURL imap in ehlo reply; continuing without BURL\n" if $submit_burl && !$smtp_burl_imap; my $ok = 1; my $expect_OK; my $explanation; for my $delivery (1..$opts{deliveries}) { $expect_OK = 1; undef $explanation; my $status = deliver($delivery); if ($status < 0) { $ok = 0; last; } elsif ($status == 0) { print "rset...\n" unless $opts{quiet}; smtp_send_data("rset\r\n"); smtp_flush(); while ($reply = $from_smtp->getline()) { smtp_printS($reply) if $opts{verbose}; $reply =~ s/[\r\n]+$//; if ($reply =~ /^\d+ /) { if ($reply !~ /250 /) { die "Rset failed: <$reply>\n"; } last; } } die "I/O error\n" if $from_smtp->error; } } print "quit...\n" unless $opts{quiet}; smtp_send_data("quit\r\n"); smtp_flush(); while ($reply = $from_smtp->getline()) { smtp_printS($reply) if $opts{verbose}; $reply =~ s/[\r\n]+$//; if ($reply =~ /^\d+ /) { if ($reply !~ /221 /) { die "Quit failed: <$reply>\n"; } last; } } die "I/O error\n" if $from_smtp->error; $to_smtp->close(); if (defined($smtppid)) { $from_smtp->close(); waitpid($smtppid, 0); undef $smtppid; } print "logout...\n" unless $opts{quiet}; if ($imap_idle) { imap_send_data("done\r\n"); imap_flush(); while ($reply = $from_imap->getline()) { imap_printS($reply) if $opts{verbose}; $reply =~ s/[\r\n]+$//; if ($reply =~ /^i /) { if ($reply !~ /i OK (\[.*\])?/) { die "Idle failed: <$reply>\n"; } last; } } die "I/O error\n" if $from_imap->error; $imap_idle = 0; } imap_send_data("z logout\r\n"); imap_flush(); while ($reply = $from_imap->getline()) { imap_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 deliver { my $delivery = shift or die; my $dtag = "deliver$delivery"; my $ctag = "check$delivery"; my @formats = ("", " body=8bitmime", " body=binarymime"); my $r = int(rand(10)); if ($r < 2) { $r = 0; } elsif ($r < 4) { $r = 1; } else { $r = 2; } my $format = $formats[$r]; if (int(rand(20)) == 0) { failif(1, "sent no MAIL command"); } else { print "$dtag (mail)...\n" unless $opts{quiet}; smtp_send_data("mail from: $dtag$format\r\n"); smtp_flush(); while ($reply = $from_smtp->getline()) { smtp_printS($reply) if $opts{verbose}; $reply =~ s/[\r\n]+$//; if ($reply =~ /^\d+ /) { if ($reply !~ /^250 /) { die "Mail failed: <$reply>\n"; } last; } } die "I/O error\n" if $from_smtp->error; } if (int(rand(20)) == 0) { failif(1, "sent no RCPT command"); } else { print "$dtag (rcpt)...\n" unless $opts{quiet}; smtp_send_data("rcpt to: $opts{user}\r\n"); smtp_flush(); while ($reply = $from_smtp->getline()) { smtp_printS($reply) if $opts{verbose}; $reply =~ s/[\r\n]+$//; if ($reply =~ /^\d+ /) { if ($expect_OK) { if ($reply !~ /^250 /) { print STDERR "Rcpt failed but should have succeeded: <$reply>\n"; return -1; } } else { if ($reply =~ /^250 /) { print STDERR "Rcpt command succeeded but should have failed ($explanation): <$reply>\n"; return -1; } else { print "Rcpt command failed as it should have ($explanation): <$reply>\n" unless $opts{quiet}; return 0; } } last; } } die "I/O error\n" if $from_smtp->error; } my ($message, $cleaned, $rawsections, $cleansections, $fetchable) = message($format, $dtag); my @rawsections = @$rawsections; my @cleansections = @$cleansections; my @fetchable = @$fetchable; print "=== RAW ===\n$message". "\n=== CLEAN ===\n$cleaned". "\n=== RAW SECTIONS ===\n".join("//\n",@rawsections). "\n=== CLEAN SECTIONS ===\n".join("//\n",@cleansections). "\n=== FETCHABLE ===\n".join("//\n", (map { defined($_) ? $_ : "" } @fetchable)). "\n=== END ===\n" if $opts{debug}; die "Internal consistency botch: rawsections has ".scalar(@rawsections)." but fetchable has ".scalar(@fetchable)."\n" unless @rawsections == @fetchable; die "Internal consistency botch: cleansections has ".scalar(@cleansections)." but fetchable has ".scalar(@fetchable)."\n" unless @cleansections == @fetchable; my @fragments; my $burl_ok; if (int(rand(2)) == 0) { # break the message up into random fragments, don't use burl my $consumed = ""; my $remaining = $message; my $stuck = 0; do { my $cut = int(rand(length($remaining) + 1)); # 0 is ok my $fragment = substr($remaining, 0, $cut); # postfix does not handle fragmented header labels (e.g., "Fr" + "om: foo") # or fragmented MIME separators (e.g., "--Apple-Ma" + "il-57-197753312--") # also avoid breaking a header at a space (e.g., "From: foo" + " |$linecont|\n...".substr($consumed,-20)."|<-HERE->|".substr($remaining,0,20)."...\n" if $opts{debug}; if (++$stuck >= 1000) { print "Can't fragment this message, giving up on it.\n" unless $opts{quiet}; return 0; } } } while (length $remaining > 0); $burl_ok = 0; my $sanity = ""; $sanity .= $_ for @fragments; die "Internal consistency botch: fragmented message does not match whole.\nWhole:\n$message\nFragmented:\n$sanity\n" unless $sanity eq $message; } else { # break the message up into natural fragments, can use burl @fragments = @fetchable; $burl_ok = 1; my $sanity = ""; $sanity .= $_ for @rawsections; # sanity needs headers die "Internal consistency botch: sectioned message does not match whole.\nRaw:\n$message\nSectioned:\n$sanity\n" unless $sanity eq $message; $sanity = ""; $sanity .= $_ for @cleansections; # sanity needs headers die "Internal consistency botch: sectioned message does not match whole.\nClean:\n$cleaned\nSectioned:\n$sanity\n" unless $sanity eq $cleaned; } my $secno = 0; my $lasturl; for my $fragno (1..@fragments) { my $fragment = $fragments[$fragno - 1]; ++$secno if defined $fragment; my $r = int(rand(20)); if ($r == 0) { print "$dtag (data)...\n" unless $opts{quiet}; smtp_send_data("data\r\n"); smtp_flush(); failif($fragno > 1, "mixed BDAT/BURL/DATA commands"); failif(scalar($format =~ /binarymime/i), "DATA with BINARYMIME"); while ($reply = $from_smtp->getline()) { smtp_printS($reply) if $opts{verbose}; $reply =~ s/[\r\n]+$//; if ($reply =~ /^\d+ /) { if ($expect_OK) { if ($reply !~ /^3\d\d /) { print STDERR "Data failed but should have succeeded: <$reply>\n"; return -1; } } else { if ($reply =~ /^[23]\d\d /) { print STDERR "Data succeeded but should have failed ($explanation): <$reply>\n"; return -1; } else { print "Data failed as it should have ($explanation): <$reply>\n" unless $opts{quiet}; return 0; } } last; } } die "I/O error\n" if $from_smtp->error; print "$dtag (message)...\n" unless $opts{quiet}; smtp_send_data($message); # send $message not $fragment #smtp_send_data("\r\n") unless $message =~ /\r\n$/s; die unless $message =~ /\r\n\z/; smtp_send_data(".\r\n"); smtp_flush(); while ($reply = $from_smtp->getline()) { smtp_printS($reply) if $opts{verbose}; $reply =~ s/[\r\n]+$//; if ($reply =~ /^\d+ /) { if ($expect_OK) { if ($reply !~ /^250 /) { print STDERR "Data transaction failed but should have succeeded: <$reply>\n"; return -1; } } else { if ($reply =~ /^250 /) { print STDERR "Data transaction succeeded but should have failed ($explanation): <$reply>\n"; return -1; } else { print "Data transaction failed as it should have ($explanation): <$reply>\n" unless $opts{quiet}; return 0; } } last; } } die "I/O error\n" if $from_smtp->error; last; # sent whole message, go check receipt } elsif ($r <= 8 && $smtp_burl_imap && $burl_ok && defined($fragment) && $rawsections[$fragno - 2] !~ /Content-transfer-encoding: binary/i && $fragment !~ /Content-transfer-encoding: binary/i) { print "$dtag (burl append)...\n" unless $opts{quiet}; imap_send_data("done\r\n"); imap_flush(); while ($reply = $from_imap->getline()) { imap_printS($reply) if $opts{verbose}; $reply =~ s/[\r\n]+$//; if ($reply =~ /^i /) { if ($reply !~ /i OK (\[.*\])?/) { die "Idle failed: <$reply>\n"; } last; } } die "I/O error\n" if $from_imap->error; $imap_idle = 0; my $size = length($message); imap_send_data("a$dtag append scratchbox {$size}\r\n"); imap_flush(); $reply = $from_imap->getline(); die "I/O error\n" if $from_imap->error; imap_printS($reply) if $opts{verbose}; $reply =~ s/[\r\n]+$//; if ($reply ne "+ OK") { die "Append failed: <$reply>\n"; } imap_send_data("$message\r\n"); imap_flush(); while ($reply = $from_imap->getline()) { imap_printS($reply) if $opts{verbose}; $reply =~ s/[\r\n]+$//; if ($reply =~ /^a$dtag /) { if ($reply !~ /a$dtag OK /) { die "Append failed: <$reply>\n"; } last; } } die "I/O error\n" if $from_imap->error; my ($uidvalidity, $uid) = ($reply =~ /\[APPENDUID (\d+) (\d+)\]/); die "Append reply missing APPENDUID: <$reply>\n" unless defined $uid; print "$dtag (burl genurlauth)...\n" unless $opts{quiet}; imap_send_data("g$dtag genurlauth imap://$opts{user}\@$opts{host}/scratchbox;uidvalidity=$uidvalidity/;uid=$uid/;section=$secno;urlauth=submit+$opts{user} internal\r\n"); imap_flush(); my $url; while ($reply = $from_imap->getline()) { imap_printS($reply) if $opts{verbose}; $reply =~ s/[\r\n]+$//; if ($reply =~ /^g$dtag /) { if ($reply !~ /g$dtag OK /) { die "Genurlauth failed: <$reply>\n"; } last; } elsif ($reply =~ /^\* GENURLAUTH "(.*)"/i || $reply =~ /^\* GENURLAUTH (.*)/i) { $url = $1; $lasturl = $1; } } die "I/O error\n" if $from_imap->error; die "Genurlauth returned no URL\n" unless defined $url; print "$dtag (burl idle)...\n" unless $opts{quiet}; imap_send_data("i idle\r\n"); imap_flush(); while ($reply = $from_imap->getline()) { imap_printS($reply) if $opts{verbose}; $reply =~ s/[\r\n]+$//; if ($reply =~ /^\+ /) { $imap_idle = 1; last; } elsif ($reply =~ /^i /) { die "Idle failed: <$reply>\n"; } } die "I/O error\n" if $from_imap->error; my $last = $fragno == @fragments ? " last" : ""; print "$dtag (burl$last)...\n" unless $opts{quiet}; smtp_send_data("burl $url$last\r\n"); smtp_flush(); while ($reply = $from_smtp->getline()) { smtp_printS($reply) if $opts{verbose}; $reply =~ s/[\r\n]+$//; if ($reply =~ /^\d+ /) { if ($expect_OK) { if ($reply !~ /^250 /) { print STDERR "Burl failed but should have succeeded: <$reply>\n"; return -1; } } else { if ($reply =~ /^250 /) { print STDERR "Burl succeeded but should have failed ($explanation): <$reply>\n"; return -1; } else { print "Burl failed as it should have ($explanation): <$reply>\n" unless $opts{quiet}; return 0; } } last; } } die "I/O error\n" if $from_smtp->error; } else { $fragment = $rawsections[$fragno - 1] unless defined $fragment; my $last = $fragno == @fragments ? " last" : ""; print "$dtag (bdat$last)...\n" unless $opts{quiet}; my $size = length($fragment); smtp_send_data("bdat $size$last\r\n"); smtp_send_data($fragment); smtp_flush(); while ($reply = $from_smtp->getline()) { smtp_printS($reply) if $opts{verbose}; $reply =~ s/[\r\n]+$//; if ($reply =~ /^\d+ /) { if ($expect_OK) { if ($reply !~ /^250 /) { print STDERR "Bdat failed but should have succeeded: <$reply>\n"; return -1; } } else { if ($reply =~ /^250 /) { print STDERR "Bdat succeeded but should have failed ($explanation): <$reply>\n"; return -1; } else { print "Bdat failed as it should have ($explanation): <$reply>\n" unless $opts{quiet}; return 0; } } last; } } die "I/O error\n" if $from_smtp->error; } } while (int(rand(4)) == 0) { # make sure extra bdat or burl fails # make sure bdat input is properly eaten on error my $cmd = defined $lasturl ? "burl" : "bdat"; my $last = int(rand(2)) == 0 ? " last" : ""; print "$dtag ($cmd$last)...\n" unless $opts{quiet}; if ($cmd eq "burl") { smtp_send_data("burl $lasturl$last\r\n"); } else { smtp_send_data("bdat 6$last\r\nfail\r\n"); } smtp_flush(); failif(1, "BURL/BDAT after DATA or LAST"); while ($reply = $from_smtp->getline()) { smtp_printS($reply) if $opts{verbose}; $reply =~ s/[\r\n]+$//; if ($reply =~ /^\d+ /) { if ($reply =~ /^250 /) { print STDERR "Extra bdat/burl succeeded but should have failed ($explanation): <$reply>\n"; return -1; } last; } } die "I/O error\n" if $from_smtp->error; } # now verify correct receipt print "waiting for receipt...\n" unless $opts{quiet}; die unless $imap_idle; my ($exists, $recent); my $keepalive = 0; while ($reply = $from_imap->getline()) { imap_printS($reply) if $opts{verbose}; $reply =~ s/[\r\n]+$//; if ($reply =~ /^\* (\d+) EXISTS/i) { $exists = $1; } elsif ($reply =~ /^\* (\d+) RECENT/i) { $recent = $1; } elsif ($reply =~ /^\* OK/) { ++$keepalive; } last if defined $exists and defined $recent; last if $keepalive >= 2; # 2 minutes per... } die "I/O error\n" if $from_imap->error; imap_send_data("done\r\n"); imap_flush(); while ($reply = $from_imap->getline()) { imap_printS($reply) if $opts{verbose}; $reply =~ s/[\r\n]+$//; if ($reply =~ /^i /) { if ($reply !~ /i OK (\[.*\])?/) { die "Idle failed: <$reply>\n"; } last; } } die "I/O error\n" if $from_imap->error; $imap_idle = 0; if (!defined($exists)) { # idle failed for some reason. try closing and reselecting the inbox print STDERR "Warning: IMAP IDLE command did not inform of the new message.\n" . "Trying to recover but the message may be stuck in the queue....\n"; print "close...\n" unless $opts{quiet}; imap_send_data("x close\r\n"); imap_flush(); while ($reply = $from_imap->getline()) { imap_printS($reply) if $opts{verbose}; $reply =~ s/[\r\n]+$//; if ($reply =~ /^x /) { last; } } die "I/O error\n" if $from_imap->error; print "select...\n" unless $opts{quiet}; imap_send_data("c select inbox\r\n"); imap_flush(); while ($reply = $from_imap->getline()) { imap_printS($reply) if $opts{verbose}; $reply =~ s/[\r\n]+$//; if ($reply =~ /^c OK /) { last; } elsif ($reply =~ /^\* (\d+) EXISTS/i) { $exists = $1; } } die "I/O error\n" if $from_imap->error; die "Can't determine number of messages in INBOX\n" if !defined($exists); } if ($exists <= $inbox_message_count) { print STDERR "Message not delivered. (EXISTS $exists now, $inbox_message_count before)\n"; return -1; } $inbox_message_count = $exists; my $cleaned_len = length($cleaned); print "fetch...\n" unless $opts{quiet}; imap_send_data("$ctag fetch $exists rfc822\r\n"); imap_flush(); my $verify = ""; my $verify_size = 0; while ($reply = $from_imap->getline()) { imap_printS($reply) if $opts{verbose}; $reply =~ s/[\r\n]+$//; if ($reply =~ /^$ctag /) { if ($reply !~ /$ctag OK (\[.*\])?/) { die "Fetch failed: <$reply>\n"; } last; } elsif ($reply =~ /^\* (\d+) FETCH .*{(\d+)}/i) { if ($1 != $exists) { die "Fetch returned wrong message $1, expected $exists\n"; } elsif ($2 < $cleaned_len) { print STDERR "Fetch returned wrong size $2, expected >= $cleaned_len\n"; } $verify_size = $2; } else { $verify .= "$reply\r\n"; } } die "I/O error\n" if $from_imap->error; $verify =~ s/\)\r\n$//; if ($verify_size < $cleaned_len || !message_fuzzy_equal($verify, $cleaned)) { print STDERR "Fetched data does not match delivered message.\nFormat: $format\nOriginal:\n$message\nExpected:\n$cleaned\nGot:\n$verify\n"; return -1; } print "idle...\n" unless $opts{quiet}; imap_send_data("i idle\r\n"); imap_flush(); while ($reply = $from_imap->getline()) { imap_printS($reply) if $opts{verbose}; $reply =~ s/[\r\n]+$//; if ($reply =~ /^\+ /) { $imap_idle = 1; last; } elsif ($reply =~ /^i /) { die "Idle failed: <$reply>\n"; } } die "I/O error\n" if $from_imap->error; print "Delivery and fetch succeeded and matched.\n" unless $opts{quiet}; return $expect_OK; } sub message { my $format = shift; my $tag = shift or die; my $message = ""; my $cleaned = ""; my @rawsections = (); my @cleansections = (); my @fetchable = (); my $encoding; do { # 33% chance for 7bit, 8bit, binary $encoding = $encodings_top[int(rand(@encodings_top - 1)) + 1]; } while (!sub_encoding_allowed($encoding)); # 16% "", 16% 7bit, 33% 8bit, 33% binary $encoding = "" if $encoding eq "7bit" && int(rand(2)) == 0; my $type; do { # like above $type = $types[int(rand(@types - 1)) + 1]; } while (!type_encoding_allowed($type, $encoding)); $type = "" if $type eq "text/plain" && int(rand(2)) == 0; my @funcs = @{$typefuncs{$type}}; my $header_func = $funcs[0]; my $body_func = $funcs[1]; my ($type_header, $context) = $header_func->($type); my $am_top = !defined($top_encoding); my @headers; push @headers, "Message-Id: <$tag-".int(rand(2_000_000_000))."\@bdat.pl>"; push @headers, "From: Bdat Script "; push @headers, "To: $opts{user}\@$opts{host}"; push @headers, $type_header unless $type eq ""; push @headers, "Content-Transfer-Encoding: $encoding" unless $encoding eq ""; push @headers, "Subject: test message $tag from bdat.pl pid $$"; push @headers, "MIME-Version: 1.0"; @headers = shuffle(@headers); push @headers, ""; my $header = join("\r\n", @headers) . "\r\n"; $message .= $header; push @rawsections, $header if $am_top; if ($format =~ /binarymime/) { if ($type =~ /multipart/ || $type =~ /message/) { $header =~ s/(content-transfer-encoding): binary/$1: 7bit/i; } else { $header =~ s/(content-transfer-encoding): binary/$1: base64/i; } } $cleaned .= $header; if ($am_top) { $top_encoding = $encoding; push @cleansections, $header; push @fetchable, undef; # top-level headers not available via urlfetch } my ($fullpart, $cleanpart, $rawsectionspart, $cleansectionspart, $fetchablepart) = $body_func->($format, $encoding, $context); $message .= $fullpart; $cleaned .= $cleanpart; if ($am_top) { undef $top_encoding; push @rawsections, @$rawsectionspart; push @cleansections, @$cleansectionspart; push @fetchable, @$fetchablepart; die unless @rawsections == @cleansections; if (@rawsections > 0 && $rawsections[$#rawsections] eq $cleansections[$#cleansections] && substr($rawsections[$#rawsections], -2) ne "\r\n") { die if substr($message, -2) eq "\r\n"; die if substr($cleaned, -2) eq "\r\n"; $message .= "\r\n"; $cleaned .= "\r\n"; $rawsections[$#rawsections] .= "\r\n"; $cleansections[$#cleansections] .= "\r\n"; $fetchable[$#fetchable] .= "\r\n" if defined($fetchable[$#fetchable]); } } else { push @rawsections, $message; push @cleansections, $cleaned; push @fetchable, $cleaned; } return ($message, $cleaned, \@rawsections, \@cleansections, \@fetchable); } sub header_plain { my $type = shift; my $header = "Content-type: $type"; return ($header, undef); } sub body_plain { my $format = shift; my $encoding = shift; my @words = ("", "I", "hi", "cod", "sole", "shark", "salmon", "walleye", "flounder", "orange roughy"); push @words, <($format, $word); $word = $clean if $encoding eq "base64" || $encoding eq "quoted-printable"; return ($word, $clean, [$word], [$clean], [$word]); } sub header_message { return header_plain(shift); } sub body_message { my $format = shift; return message($format, "encapsulated"); } sub header_gibberish { return header_plain(shift); } sub body_gibberish { my $format = shift; my $encoding = shift; my ($gibberish, $clean); do { $gibberish = ""; my $length = int(rand(5000)) + 1; $gibberish .= chr(int(rand(256))) for (1..$length); if ($encoding =~ /8bit/) { $gibberish =~ s/\0//g; # 8bit forbids NUL 1 while $gibberish =~ s/(\A|[^\r])\n/$1\r\n/g; # 8bit is line-oriented 1 while $gibberish =~ s/\r([^\n]|\z)/\r\n$1/g; # 8bit is line-oriented $gibberish .= "\r\n" unless substr($gibberish, -2) eq "\r\n"; } $clean = $encodingfuncs{$encoding}->($format, $gibberish); # . at beginning of line will be removed, so try again } while (($encoding ne "binary" || $format !~ /binarymime/) && ($gibberish =~ /^\./m || $clean =~ /^\./m)); $gibberish = $clean if $encoding eq "base64" || $encoding eq "quoted-printable"; return ($gibberish, $clean, [$gibberish], [$clean], [$gibberish]); } sub header_mixed { my $type = shift; my $sep = "sep" . int(rand(2_000_000_000)); my $header = "Content-type: $type;\r\n\tboundary=$sep"; return ($header, $sep); } sub body_mixed { my $format = shift; my $encoding = shift; my $sep = shift or die; my $nparts = int(rand(5)) + 1; # preamble my ($data, $clean, @rawsections, @cleansections, @fetchable); $data .= "preamble\r\n"; $clean .= "preamble\r\n"; push @rawsections, "preamble\r\n"; push @cleansections, "preamble\r\n"; push @fetchable, undef; for my $partno (1..$nparts) { my $partencoding; do { $partencoding = $encodings_sub[int(rand(@encodings_sub - 1)) + 1]; } while (!sub_encoding_allowed($partencoding)); $partencoding = "" if $partencoding eq "7bit" && int(rand(2)) == 0; my $parttype; do { $parttype = $types[int(rand(@types - 1)) + 1]; } while (!type_encoding_allowed($parttype, $partencoding)); $parttype = "" if $parttype eq "text/plain" && int(rand(2)) == 0; my @partfuncs = @{$typefuncs{$parttype}}; my $partheader_func = $partfuncs[0]; my $partbody_func = $partfuncs[1]; my ($parttype_header, $partcontext) = $partheader_func->($parttype); my @partheaders; push @partheaders, $parttype_header unless $parttype eq ""; push @partheaders, "Content-transfer-encoding: $partencoding" unless $partencoding eq ""; push @partheaders, "Mime-version: 1.0" if int(rand(2)) == 0; push @partheaders, "Content-disposition: inline" if int(rand(2)) == 0; @partheaders = shuffle(@partheaders); unshift @partheaders, "\r\n--$sep"; push @partheaders, ""; my $partheader = join("\r\n", @partheaders) . "\r\n"; $data .= $partheader; push @rawsections, $partheader; if ($format =~ /binarymime/) { if ($parttype =~ /multipart/ || $parttype =~ /message/) { $partheader =~ s/(content-transfer-encoding): binary/$1: 7bit/i; } else { $partheader =~ s/(content-transfer-encoding): binary/$1: base64/i; } } $clean .= $partheader; push @cleansections, $partheader; push @fetchable, undef; my ($partfull, $partclean, undef, undef, undef) = $partbody_func->($format, $partencoding, $partcontext); if ($partno < $nparts && int(rand(2)) == 0 && $partfull eq $partclean) { # make sure sections not ending with linebreaks work # but only if the clean hasn't already been folded into base64 if (substr($partfull, -2) eq "\r\n") { $partfull =~ s/\r\n\z//; $partclean =~ s/\r\n\z//; } elsif (substr($partfull, -1) eq "\n") { $partfull =~ s/\n\z//; $partclean =~ s/\n\z//; } } $data .= $partfull; $clean .= $partclean; push @rawsections, $partfull; # don't need divided subsections push @cleansections, $partclean; # don't need divided subsections push @fetchable, $partfull; # don't need divided subsections } $data .= "\r\n--$sep--\r\n"; $clean .= "\r\n--$sep--\r\n"; push @rawsections, "\r\n--$sep--\r\n"; push @cleansections, "\r\n--$sep--\r\n"; push @fetchable, undef; # epilogue $data .= "epilogue\r\n"; $clean .= "epilogue\r\n"; push @rawsections, "epilogue\r\n"; push @cleansections, "epilogue\r\n"; push @fetchable, undef; return ($data, $clean, \@rawsections, \@cleansections, \@fetchable); } sub type_encoding_allowed { my $type = shift; my $encoding = shift; if ($type =~ m,message/, || $type =~ m,multipart/,) { return $encoding ne "base64" && $encoding ne "quoted-printable"; } elsif ($type =~ m,application/,) { return $encoding ne "" && $encoding ne "7bit"; } return 1; } sub sub_encoding_allowed { my $sub_encoding = shift; return 1 if !defined($top_encoding); if ($sub_encoding eq "8bit") { return $top_encoding eq "8bit" || $top_encoding eq "binary"; } elsif ($sub_encoding eq "binary") { return $top_encoding eq "binary"; } return 1; } sub clean_7bit { my $format = shift; my $data = shift; return $data; } sub clean_8bit { my $format = shift; my $data = shift; return $data; } sub clean_binary { my $format = shift; my $data = shift; return ($format =~ /binarymime/) ? clean_base64($format, $data) : $data; } sub clean_base64 { my $format = shift; my $raw = shift; my $b64 = APR::Base64::encode($raw); $b64 =~ s/(.{76})(?=.)/$1\r\n/g; return $b64; } sub clean_qp { my $format = shift; my $raw = shift; my $qp = encode_qp($raw, "\r\n"); return $qp; } sub message_fuzzy_equal { my $actual = shift; my $expected = shift; # SMTP adds/modifies headers; perform fuzzy match $actual =~ s/\*\*\*JUNK MAIL\*\*\* //i; $actual =~ s/^(Date|Return-Path|Delivered-To|Received|X-Virus-Scanned|X-Amavis-Alert|X-Spam-[a-z]+): [^\n]+(\n\s[^\n]+)*\n//mgi; # during delivery of non-8bit-conforming gibberish, NUL becomes 0x80 and CRLF is enforced $actual =~ s/\0/\200/g; $expected =~ s/\0/\200/g; $actual =~ s/\r{2,}\n/\r\n/g; $expected =~ s/\r{2,}\n/\r\n/g; 1 while $actual =~ s/(\A|[^\r])\n/$1\r\n/g; 1 while $actual =~ s/\r([^\n]|\z)/\r\n$1/g; 1 while $expected =~ s/(\A|[^\r])\n/$1\r\n/g; 1 while $expected =~ s/\r([^\n]|\z)/\r\n$1/g; # the Content-Transfer-Encoding header(s) may be reordered but must still match my @actual_encodings; my @expected_encodings; while ($actual =~ s/^Content-Transfer-Encoding: ([^\n]+(\n\s[^\n]+)*)\n//mi) { my $cte = $1; $cte =~ s/\r//g; push @actual_encodings, $cte; } while ($expected =~ s/^Content-Transfer-Encoding: ([^\n]+(\n\s[^\n]+)*)\n//mi) { my $cte = $1; $cte =~ s/\r//g; push @expected_encodings, $cte; } my $actual_encodings = join(",", @actual_encodings); my $expected_encodings = join(",", @expected_encodings); print "=== EDITED ACTUAL (Content-Transfer-Encodings: $actual_encodings) ===\n$actual\n" . "=== EDITED EXPECTED (Content-Transfer-Encodings: $expected_encodings) ===\n$expected\n" . "=== END ===\n" if $opts{debug}; return 1 if $actual_encodings eq $expected_encodings && ($actual eq $expected || $actual eq "$expected\r\n"); return 0; } sub imap_flush { imap_send_data(undef); } sub imap_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)) { imap_printC($buf) if $opts{verbose}; $to_imap->print($buf); undef $bufsiz; $buf = ""; } } sub smtp_flush { smtp_send_data(undef); } sub smtp_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)) { smtp_printC($buf) if $opts{verbose}; $to_smtp->print($buf); undef $bufsiz; $buf = ""; } } sub imap_printC { my $msg = shift; imap_printX("C", $msg); print "~FLUSH~" if $opts{buftag}; } sub imap_printS { imap_printX("S", @_); } sub imap_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 smtp_printC { my $msg = shift; smtp_printX("C", $msg); print "~FLUSH~" if $opts{buftag}; } sub smtp_printS { smtp_printX("S", @_); } sub smtp_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; } }