ImapResponseParser.java revision f419287f22ae44f25e1ba1f757ec33c7941bbfa8
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
17package com.android.email.mail.store.imap;
18
19import android.text.TextUtils;
20import android.util.Log;
21
22import com.android.email.FixedLengthInputStream;
23import com.android.email.PeekableInputStream;
24import com.android.email.mail.transport.DiscourseLogger;
25import com.android.email2.ui.MailActivityEmail;
26import com.android.emailcommon.Logging;
27import com.android.emailcommon.mail.MessagingException;
28import com.android.emailcommon.utility.LoggingInputStream;
29
30import java.io.IOException;
31import java.io.InputStream;
32import java.util.ArrayList;
33
34/**
35 * IMAP response parser.
36 */
37public 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 && MailActivityEmail.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 (MailActivityEmail.DEBUG) {
103            Log.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 (MailActivityEmail.DEBUG) {
165                Log.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            Log.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        Log.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        expect('\r');
442        expect('\n');
443        FixedLengthInputStream in = new FixedLengthInputStream(mIn, size);
444        if (size > mLiteralKeepInMemoryThreshold) {
445            return new ImapTempFileLiteral(in);
446        } else {
447            return new ImapMemoryLiteral(in);
448        }
449    }
450}
451