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