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