Home | History | Annotate | Download | only in imap
      1 /*
      2  * Copyright (C) 2010 The Android Open Source Project
      3  *
      4  * Licensed under the Apache License, Version 2.0 (the "License");
      5  * you may not use this file except in compliance with the License.
      6  * You may obtain a copy of the License at
      7  *
      8  *      http://www.apache.org/licenses/LICENSE-2.0
      9  *
     10  * Unless required by applicable law or agreed to in writing, software
     11  * distributed under the License is distributed on an "AS IS" BASIS,
     12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
     13  * See the License for the specific language governing permissions and
     14  * limitations under the License.
     15  */
     16 
     17 package com.android.email.mail.store.imap;
     18 
     19 import android.text.TextUtils;
     20 
     21 import com.android.email.DebugUtils;
     22 import com.android.email.FixedLengthInputStream;
     23 import com.android.email.PeekableInputStream;
     24 import com.android.email.mail.transport.DiscourseLogger;
     25 import com.android.emailcommon.Logging;
     26 import com.android.emailcommon.mail.MessagingException;
     27 import com.android.emailcommon.utility.LoggingInputStream;
     28 import com.android.mail.utils.LogUtils;
     29 
     30 import java.io.IOException;
     31 import java.io.InputStream;
     32 import java.util.ArrayList;
     33 
     34 /**
     35  * IMAP response parser.
     36  */
     37 public class ImapResponseParser {
     38     private static final boolean DEBUG_LOG_RAW_STREAM = false; // DO NOT RELEASE AS 'TRUE'
     39 
     40     /**
     41      * Literal larger than this will be stored in temp file.
     42      */
     43     public static final int LITERAL_KEEP_IN_MEMORY_THRESHOLD = 2 * 1024 * 1024;
     44 
     45     /** Input stream */
     46     private final PeekableInputStream mIn;
     47 
     48     /**
     49      * To log network activities when the parser crashes.
     50      *
     51      * <p>We log all bytes received from the server, except for the part sent as literals.
     52      */
     53     private final DiscourseLogger mDiscourseLogger;
     54 
     55     private final int mLiteralKeepInMemoryThreshold;
     56 
     57     /** StringBuilder used by readUntil() */
     58     private final StringBuilder mBufferReadUntil = new StringBuilder();
     59 
     60     /** StringBuilder used by parseBareString() */
     61     private final StringBuilder mParseBareString = new StringBuilder();
     62 
     63     /**
     64      * We store all {@link ImapResponse} in it.  {@link #destroyResponses()} must be called from
     65      * time to time to destroy them and clear it.
     66      */
     67     private final ArrayList<ImapResponse> mResponsesToDestroy = new ArrayList<ImapResponse>();
     68 
     69     /**
     70      * Exception thrown when we receive BYE.  It derives from IOException, so it'll be treated
     71      * in the same way EOF does.
     72      */
     73     public static class ByeException extends IOException {
     74         public static final String MESSAGE = "Received BYE";
     75         public ByeException() {
     76             super(MESSAGE);
     77         }
     78     }
     79 
     80     /**
     81      * Public constructor for normal use.
     82      */
     83     public ImapResponseParser(InputStream in, DiscourseLogger discourseLogger) {
     84         this(in, discourseLogger, LITERAL_KEEP_IN_MEMORY_THRESHOLD);
     85     }
     86 
     87     /**
     88      * Constructor for testing to override the literal size threshold.
     89      */
     90     /* package for test */ ImapResponseParser(InputStream in, DiscourseLogger discourseLogger,
     91             int literalKeepInMemoryThreshold) {
     92         if (DEBUG_LOG_RAW_STREAM && DebugUtils.DEBUG) {
     93             in = new LoggingInputStream(in);
     94         }
     95         mIn = new PeekableInputStream(in);
     96         mDiscourseLogger = discourseLogger;
     97         mLiteralKeepInMemoryThreshold = literalKeepInMemoryThreshold;
     98     }
     99 
    100     private static IOException newEOSException() {
    101         final String message = "End of stream reached";
    102         if (DebugUtils.DEBUG) {
    103             LogUtils.d(Logging.LOG_TAG, message);
    104         }
    105         return new IOException(message);
    106     }
    107 
    108     /**
    109      * Peek next one byte.
    110      *
    111      * Throws IOException() if reaches EOF.  As long as logical response lines end with \r\n,
    112      * we shouldn't see EOF during parsing.
    113      */
    114     private int peek() throws IOException {
    115         final int next = mIn.peek();
    116         if (next == -1) {
    117             throw newEOSException();
    118         }
    119         return next;
    120     }
    121 
    122     /**
    123      * Read and return one byte from {@link #mIn}, and put it in {@link #mDiscourseLogger}.
    124      *
    125      * Throws IOException() if reaches EOF.  As long as logical response lines end with \r\n,
    126      * we shouldn't see EOF during parsing.
    127      */
    128     private int readByte() throws IOException {
    129         int next = mIn.read();
    130         if (next == -1) {
    131             throw newEOSException();
    132         }
    133         mDiscourseLogger.addReceivedByte(next);
    134         return next;
    135     }
    136 
    137     /**
    138      * Destroy all the {@link ImapResponse}s stored in the internal storage and clear it.
    139      *
    140      * @see #readResponse()
    141      */
    142     public void destroyResponses() {
    143         for (ImapResponse r : mResponsesToDestroy) {
    144             r.destroy();
    145         }
    146         mResponsesToDestroy.clear();
    147     }
    148 
    149     /**
    150      * Reads the next response available on the stream and returns an
    151      * {@link ImapResponse} object that represents it.
    152      *
    153      * <p>When this method successfully returns an {@link ImapResponse}, the {@link ImapResponse}
    154      * is stored in the internal storage.  When the {@link ImapResponse} is no longer used
    155      * {@link #destroyResponses} should be called to destroy all the responses in the array.
    156      *
    157      * @return the parsed {@link ImapResponse} object.
    158      * @exception ByeException when detects BYE.
    159      */
    160     public ImapResponse readResponse() throws IOException, MessagingException {
    161         ImapResponse response = null;
    162         try {
    163             response = parseResponse();
    164             if (DebugUtils.DEBUG) {
    165                 LogUtils.d(Logging.LOG_TAG, "<<< " + response.toString());
    166             }
    167 
    168         } catch (RuntimeException e) {
    169             // Parser crash -- log network activities.
    170             onParseError(e);
    171             throw e;
    172         } catch (IOException e) {
    173             // Network error, or received an unexpected char.
    174             onParseError(e);
    175             throw e;
    176         }
    177 
    178         // Handle this outside of try-catch.  We don't have to dump protocol log when getting BYE.
    179         if (response.is(0, ImapConstants.BYE)) {
    180             LogUtils.w(Logging.LOG_TAG, ByeException.MESSAGE);
    181             response.destroy();
    182             throw new ByeException();
    183         }
    184         mResponsesToDestroy.add(response);
    185         return response;
    186     }
    187 
    188     private void onParseError(Exception e) {
    189         // Read a few more bytes, so that the log will contain some more context, even if the parser
    190         // crashes in the middle of a response.
    191         // This also makes sure the byte in question will be logged, no matter where it crashes.
    192         // e.g. when parseAtom() peeks and finds at an unexpected char, it throws an exception
    193         // before actually reading it.
    194         // However, we don't want to read too much, because then it may get into an email message.
    195         try {
    196             for (int i = 0; i < 4; i++) {
    197                 int b = readByte();
    198                 if (b == -1 || b == '\n') {
    199                     break;
    200                 }
    201             }
    202         } catch (IOException ignore) {
    203         }
    204         LogUtils.w(Logging.LOG_TAG, "Exception detected: " + e.getMessage());
    205         mDiscourseLogger.logLastDiscourse();
    206     }
    207 
    208     /**
    209      * Read next byte from stream and throw it away.  If the byte is different from {@code expected}
    210      * throw {@link MessagingException}.
    211      */
    212     /* package for test */ void expect(char expected) throws IOException {
    213         final int next = readByte();
    214         if (expected != next) {
    215             throw new IOException(String.format("Expected %04x (%c) but got %04x (%c)",
    216                     (int) expected, expected, next, (char) next));
    217         }
    218     }
    219 
    220     /**
    221      * Read bytes until we find {@code end}, and return all as string.
    222      * The {@code end} will be read (rather than peeked) and won't be included in the result.
    223      */
    224     /* package for test */ String readUntil(char end) throws IOException {
    225         mBufferReadUntil.setLength(0);
    226         for (;;) {
    227             final int ch = readByte();
    228             if (ch != end) {
    229                 mBufferReadUntil.append((char) ch);
    230             } else {
    231                 return mBufferReadUntil.toString();
    232             }
    233         }
    234     }
    235 
    236     /**
    237      * Read all bytes until \r\n.
    238      */
    239     /* package */ String readUntilEol() throws IOException {
    240         String ret = readUntil('\r');
    241         expect('\n'); // TODO Should this really be error?
    242         return ret;
    243     }
    244 
    245     /**
    246      * Parse and return the response line.
    247      */
    248     private ImapResponse parseResponse() throws IOException, MessagingException {
    249         // We need to destroy the response if we get an exception.
    250         // So, we first store the response that's being built in responseToDestroy, until it's
    251         // completely built, at which point we copy it into responseToReturn and null out
    252         // responseToDestroyt.
    253         // If responseToDestroy is not null in finally, we destroy it because that means
    254         // we got an exception somewhere.
    255         ImapResponse responseToDestroy = null;
    256         final ImapResponse responseToReturn;
    257 
    258         try {
    259             final int ch = peek();
    260             if (ch == '+') { // Continuation request
    261                 readByte(); // skip +
    262                 expect(' ');
    263                 responseToDestroy = new ImapResponse(null, true);
    264 
    265                 // If it's continuation request, we don't really care what's in it.
    266                 responseToDestroy.add(new ImapSimpleString(readUntilEol()));
    267 
    268                 // Response has successfully been built.  Let's return it.
    269                 responseToReturn = responseToDestroy;
    270                 responseToDestroy = null;
    271             } else {
    272                 // Status response or response data
    273                 final String tag;
    274                 if (ch == '*') {
    275                     tag = null;
    276                     readByte(); // skip *
    277                     expect(' ');
    278                 } else {
    279                     tag = readUntil(' ');
    280                 }
    281                 responseToDestroy = new ImapResponse(tag, false);
    282 
    283                 final ImapString firstString = parseBareString();
    284                 responseToDestroy.add(firstString);
    285 
    286                 // parseBareString won't eat a space after the string, so we need to skip it,
    287                 // if exists.
    288                 // If the next char is not ' ', it should be EOL.
    289                 if (peek() == ' ') {
    290                     readByte(); // skip ' '
    291 
    292                     if (responseToDestroy.isStatusResponse()) { // It's a status response
    293 
    294                         // Is there a response code?
    295                         final int next = peek();
    296                         if (next == '[') {
    297                             responseToDestroy.add(parseList('[', ']'));
    298                             if (peek() == ' ') { // Skip following space
    299                                 readByte();
    300                             }
    301                         }
    302 
    303                         String rest = readUntilEol();
    304                         if (!TextUtils.isEmpty(rest)) {
    305                             // The rest is free-form text.
    306                             responseToDestroy.add(new ImapSimpleString(rest));
    307                         }
    308                     } else { // It's a response data.
    309                         parseElements(responseToDestroy, '\0');
    310                     }
    311                 } else {
    312                     expect('\r');
    313                     expect('\n');
    314                 }
    315 
    316                 // Response has successfully been built.  Let's return it.
    317                 responseToReturn = responseToDestroy;
    318                 responseToDestroy = null;
    319             }
    320         } finally {
    321             if (responseToDestroy != null) {
    322                 // We get an exception.
    323                 responseToDestroy.destroy();
    324             }
    325         }
    326 
    327         return responseToReturn;
    328     }
    329 
    330     private ImapElement parseElement() throws IOException, MessagingException {
    331         final int next = peek();
    332         switch (next) {
    333             case '(':
    334                 return parseList('(', ')');
    335             case '[':
    336                 return parseList('[', ']');
    337             case '"':
    338                 readByte(); // Skip "
    339                 return new ImapSimpleString(readUntil('"'));
    340             case '{':
    341                 return parseLiteral();
    342             case '\r':  // CR
    343                 readByte(); // Consume \r
    344                 expect('\n'); // Should be followed by LF.
    345                 return null;
    346             case '\n': // LF // There shouldn't be a bare LF, but just in case.
    347                 readByte(); // Consume \n
    348                 return null;
    349             default:
    350                 return parseBareString();
    351         }
    352     }
    353 
    354     /**
    355      * Parses an atom.
    356      *
    357      * Special case: If an atom contains '[', everything until the next ']' will be considered
    358      * a part of the atom.
    359      * (e.g. "BODY[HEADER.FIELDS ("DATE" ...)]" will become a single ImapString)
    360      *
    361      * If the value is "NIL", returns an empty string.
    362      */
    363     private ImapString parseBareString() throws IOException, MessagingException {
    364         mParseBareString.setLength(0);
    365         for (;;) {
    366             final int ch = peek();
    367 
    368             // TODO Can we clean this up?  (This condition is from the old parser.)
    369             if (ch == '(' || ch == ')' || ch == '{' || ch == ' ' ||
    370                     // ']' is not part of atom (it's in resp-specials)
    371                     ch == ']' ||
    372                     // docs claim that flags are \ atom but atom isn't supposed to
    373                     // contain
    374                     // * and some flags contain *
    375                     // ch == '%' || ch == '*' ||
    376                     ch == '%' ||
    377                     // TODO probably should not allow \ and should recognize
    378                     // it as a flag instead
    379                     // ch == '"' || ch == '\' ||
    380                     ch == '"' || (0x00 <= ch && ch <= 0x1f) || ch == 0x7f) {
    381                 if (mParseBareString.length() == 0) {
    382                     throw new MessagingException("Expected string, none found.");
    383                 }
    384                 String s = mParseBareString.toString();
    385 
    386                 // NIL will be always converted into the empty string.
    387                 if (ImapConstants.NIL.equalsIgnoreCase(s)) {
    388                     return ImapString.EMPTY;
    389                 }
    390                 return new ImapSimpleString(s);
    391             } else if (ch == '[') {
    392                 // Eat all until next ']'
    393                 mParseBareString.append((char) readByte());
    394                 mParseBareString.append(readUntil(']'));
    395                 mParseBareString.append(']'); // readUntil won't include the end char.
    396             } else {
    397                 mParseBareString.append((char) readByte());
    398             }
    399         }
    400     }
    401 
    402     private void parseElements(ImapList list, char end)
    403             throws IOException, MessagingException {
    404         for (;;) {
    405             for (;;) {
    406                 final int next = peek();
    407                 if (next == end) {
    408                     return;
    409                 }
    410                 if (next != ' ') {
    411                     break;
    412                 }
    413                 // Skip space
    414                 readByte();
    415             }
    416             final ImapElement el = parseElement();
    417             if (el == null) { // EOL
    418                 return;
    419             }
    420             list.add(el);
    421         }
    422     }
    423 
    424     private ImapList parseList(char opening, char closing)
    425             throws IOException, MessagingException {
    426         expect(opening);
    427         final ImapList list = new ImapList();
    428         parseElements(list, closing);
    429         expect(closing);
    430         return list;
    431     }
    432 
    433     private ImapString parseLiteral() throws IOException, MessagingException {
    434         expect('{');
    435         final int size;
    436         try {
    437             size = Integer.parseInt(readUntil('}'));
    438         } catch (NumberFormatException nfe) {
    439             throw new MessagingException("Invalid length in literal");
    440         }
    441         if (size < 0) {
    442             throw new MessagingException("Invalid negative length in literal");
    443         }
    444         expect('\r');
    445         expect('\n');
    446         FixedLengthInputStream in = new FixedLengthInputStream(mIn, size);
    447         if (size > mLiteralKeepInMemoryThreshold) {
    448             return new ImapTempFileLiteral(in);
    449         } else {
    450             return new ImapMemoryLiteral(in);
    451         }
    452     }
    453 }
    454