package com.fsck.k9.mail.store.imap;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import com.fsck.k9.mail.K9MailLib;
import com.fsck.k9.mail.filter.FixedLengthInputStream;
import com.fsck.k9.mail.filter.PeekableInputStream;
import timber.log.Timber;
import static com.fsck.k9.mail.K9MailLib.DEBUG_PROTOCOL_IMAP;
class ImapResponseParser {
private PeekableInputStream inputStream;
private ImapResponse response;
private Exception exception;
public ImapResponseParser(PeekableInputStream in) {
this.inputStream = in;
}
public ImapResponse readResponse() throws IOException {
return readResponse(null);
}
/**
* Reads the next response available on the stream and returns an {@code ImapResponse} object that represents it.
*/
public ImapResponse readResponse(ImapResponseCallback callback) throws IOException {
try {
int peek = inputStream.peek();
if (peek == '+') {
readContinuationRequest(callback);
} else if (peek == '*') {
readUntaggedResponse(callback);
} else {
readTaggedResponse(callback);
}
if (exception != null) {
throw new ImapResponseParserException("readResponse(): Exception in callback method", exception);
}
return response;
} finally {
response = null;
exception = null;
}
}
private void readContinuationRequest(ImapResponseCallback callback) throws IOException {
parseCommandContinuationRequest();
response = ImapResponse.newContinuationRequest(callback);
skipIfSpace();
String rest = readStringUntilEndOfLine();
response.add(rest);
}
private void readUntaggedResponse(ImapResponseCallback callback) throws IOException {
parseUntaggedResponse();
response = ImapResponse.newUntaggedResponse(callback);
readTokens(response);
}
private void readTaggedResponse(ImapResponseCallback callback) throws IOException {
String tag = parseTaggedResponse();
response = ImapResponse.newTaggedResponse(callback, tag);
readTokens(response);
}
List<ImapResponse> readStatusResponse(String tag, String commandToLog, String logId,
UntaggedHandler untaggedHandler) throws IOException, NegativeImapResponseException {
List<ImapResponse> responses = new ArrayList<ImapResponse>();
ImapResponse response;
do {
response = readResponse();
if (K9MailLib.isDebug() && DEBUG_PROTOCOL_IMAP) {
Timber.v("%s<<<%s", logId, response);
}
if (response.getTag() != null && !response.getTag().equalsIgnoreCase(tag)) {
Timber.w("After sending tag %s, got tag response from previous command %s for %s", tag, response, logId);
Iterator<ImapResponse> responseIterator = responses.iterator();
while (responseIterator.hasNext()) {
ImapResponse delResponse = responseIterator.next();
if (delResponse.getTag() != null || delResponse.size() < 2 || (
!equalsIgnoreCase(delResponse.get(1), Responses.EXISTS) &&
!equalsIgnoreCase(delResponse.get(1), Responses.EXPUNGE))) {
responseIterator.remove();
}
}
response = null;
continue;
}
if (response.getTag() == null && untaggedHandler != null) {
untaggedHandler.handleAsyncUntaggedResponse(response);
}
responses.add(response);
} while (response == null || response.getTag() == null);
if (response.size() < 1 || !equalsIgnoreCase(response.get(0), Responses.OK)) {
String message = "Command: " + commandToLog + "; response: " + response.toString();
throw new NegativeImapResponseException(message, responses);
}
return responses;
}
private void readTokens(ImapResponse response) throws IOException {
response.clear();
Object firstToken = readToken(response);
checkTokenIsString(firstToken);
String symbol = (String) firstToken;
response.add(symbol);
if (isStatusResponse(symbol)) {
parseResponseText(response);
} else if (equalsIgnoreCase(symbol, Responses.LIST) || equalsIgnoreCase(symbol, Responses.LSUB)) {
parseListResponse(response);
} else {
Object token;
while ((token = readToken(response)) != null) {
if (!(token instanceof ImapList)) {
response.add(token);
}
}
}
}
/**
* Parse {@code resp-text} tokens
* <p>
* Responses "OK", "PREAUTH", "BYE", "NO", "BAD", and continuation request responses can
* contain {@code resp-text} tokens. We parse the {@code resp-text-code} part as tokens and
* read the rest as sequence of characters to avoid the parser interpreting things like
* "{123}" as start of a literal.
* </p>
* <p>Example:</p>
* <p>
* {@code * OK [UIDVALIDITY 3857529045] UIDs valid}
* </p>
* <p>
* See RFC 3501, Section 9 Formal Syntax (resp-text)
* </p>
*
* @param parent
* The {@link ImapResponse} instance that holds the parsed tokens of the response.
*
* @throws IOException
* If there's a network error.
*
* @see #isStatusResponse(String)
*/
private void parseResponseText(ImapResponse parent) throws IOException {
skipIfSpace();
int next = inputStream.peek();
if (next == '[') {
parseList(parent, '[', ']');
skipIfSpace();
}
String rest = readStringUntilEndOfLine();
if (rest != null && !rest.isEmpty()) {
// The rest is free-form text.
parent.add(rest);
}
}
private void parseListResponse(ImapResponse response) throws IOException {
expect(' ');
parseList(response, '(', ')');
expect(' ');
String delimiter = parseQuotedOrNil();
response.add(delimiter);
expect(' ');
String name = parseString();
response.add(name);
expect('\r');
expect('\n');
}
private void skipIfSpace() throws IOException {
if (inputStream.peek() == ' ') {
expect(' ');
}
}
/**
* Reads the next token of the response. The token can be one of: String -
* for NIL, QUOTED, NUMBER, ATOM. Object - for LITERAL.
* ImapList - for PARENTHESIZED LIST. Can contain any of the above
* elements including List.
*
* @return The next token in the response or null if there are no more
* tokens.
*/
private Object readToken(ImapResponse response) throws IOException {
while (true) {
Object token = parseToken(response);
if (token == null || !(token.equals(")") || token.equals("]"))) {
return token;
}
}
}
private Object parseToken(ImapList parent) throws IOException {
while (true) {
int ch = inputStream.peek();
if (ch == '(') {
return parseList(parent, '(', ')');
} else if (ch == '[') {
return parseList(parent, '[', ']');
} else if (ch == ')') {
expect(')');
return ")";
} else if (ch == ']') {
expect(']');
return "]";
} else if (ch == '"') {
return parseQuoted();
} else if (ch == '{') {
return parseLiteral();
} else if (ch == ' ') {
expect(' ');
} else if (ch == '\r') {
expect('\r');
expect('\n');
return null;
} else if (ch == '\n') {
expect('\n');
return null;
} else if (ch == '\t') {
expect('\t');
} else {
return parseBareString(true);
}
}
}
private String parseString() throws IOException {
int ch = inputStream.peek();
if (ch == '"') {
return parseQuoted();
} else if (ch == '{') {
return (String) parseLiteral();
} else {
return parseBareString(false);
}
}
private boolean parseCommandContinuationRequest() throws IOException {
expect('+');
return true;
}
private void parseUntaggedResponse() throws IOException {
expect('*');
expect(' ');
}
private String parseTaggedResponse() throws IOException {
return readStringUntil(' ');
}
private ImapList parseList(ImapList parent, char start, char end) throws IOException {
expect(start);
ImapList list = new ImapList();
parent.add(list);
String endString = String.valueOf(end);
Object token;
while (true) {
token = parseToken(list);
if (token == null) {
return null;
} else if (token.equals(endString)) {
break;
} else if (!(token instanceof ImapList)) {
list.add(token);
}
}
return list;
}
private String parseBareString(boolean allowBrackets) throws IOException {
StringBuilder sb = new StringBuilder();
int ch;
while (true) {
ch = inputStream.peek();
if (ch == -1) {
throw new IOException("parseBareString(): end of stream reached");
}
if (ch == '(' || ch == ')' || (allowBrackets && (ch == '[' || ch == ']')) ||
ch == '{' || ch == ' ' || ch == '"' ||
(ch >= 0x00 && ch <= 0x1f) || ch == 0x7f) {
if (sb.length() == 0) {
throw new IOException(String.format("parseBareString(): (%04x %c)", ch, ch));
}
return sb.toString();
} else {
sb.append((char) inputStream.read());
}
}
}
/**
* A "{" has been read. Read the rest of the size string, the space and then notify the callback with an
* {@code InputStream}.
*/
private Object parseLiteral() throws IOException {
expect('{');
int size = Integer.parseInt(readStringUntil('}'));
expect('\r');
expect('\n');
if (size == 0) {
return "";
}
if (response.getCallback() != null) {
FixedLengthInputStream fixed = new FixedLengthInputStream(inputStream, size);
Exception callbackException = null;
Object result = null;
try {
result = response.getCallback().foundLiteral(response, fixed);
} catch (IOException e) {
throw e;
} catch (Exception e) {
callbackException = e;
}
boolean someDataWasRead = fixed.available() != size;
if (someDataWasRead) {
if (result == null && callbackException == null) {
throw new AssertionError("Callback consumed some data but returned no result");
}
fixed.skipRemaining();
}
if (callbackException != null) {
if (exception == null) {
exception = callbackException;
}
return "EXCEPTION";
}
if (result != null) {
return result;
}
}
byte[] data = new byte[size];
int read = 0;
while (read != size) {
int count = inputStream.read(data, read, size - read);
if (count == -1) {
throw new IOException("parseLiteral(): end of stream reached");
}
read += count;
}
return new String(data, "US-ASCII");
}
private String parseQuoted() throws IOException {
expect('"');
StringBuilder sb = new StringBuilder();
int ch;
boolean escape = false;
while ((ch = inputStream.read()) != -1) {
if (!escape && ch == '\\') {
// Found the escape character
escape = true;
} else if (!escape && ch == '"') {
return sb.toString();
} else {
sb.append((char) ch);
escape = false;
}
}
throw new IOException("parseQuoted(): end of stream reached");
}
private String parseQuotedOrNil() throws IOException {
int peek = inputStream.peek();
if (peek == '"') {
return parseQuoted();
} else {
parseNil();
return null;
}
}
private void parseNil() throws IOException {
expect('N');
expect('I');
expect('L');
}
private String readStringUntil(char end) throws IOException {
StringBuilder sb = new StringBuilder();
int ch;
while ((ch = inputStream.read()) != -1) {
if (ch == end) {
return sb.toString();
} else {
sb.append((char) ch);
}
}
throw new IOException("readStringUntil(): end of stream reached");
}
private String readStringUntilEndOfLine() throws IOException {
String rest = readStringUntil('\r');
expect('\n');
return rest;
}
private void expect(char expected) throws IOException {
int readByte = inputStream.read();
if (readByte != expected) {
throw new IOException(String.format("Expected %04x (%c) but got %04x (%c)",
(int) expected, expected, readByte, (char) readByte));
}
}
private boolean isStatusResponse(String symbol) {
return symbol.equalsIgnoreCase(Responses.OK) ||
symbol.equalsIgnoreCase(Responses.NO) ||
symbol.equalsIgnoreCase(Responses.BAD) ||
symbol.equalsIgnoreCase(Responses.PREAUTH) ||
symbol.equalsIgnoreCase(Responses.BYE);
}
static boolean equalsIgnoreCase(Object token, String symbol) {
if (token == null || !(token instanceof String)) {
return false;
}
return symbol.equalsIgnoreCase((String) token);
}
private void checkTokenIsString(Object token) throws IOException {
if (!(token instanceof String)) {
throw new IOException("Unexpected non-string token: " + token.getClass().getSimpleName() + " - " + token);
}
}
}