blob: 2db1f4791e0f50ac31f4e141cc8b786bfc12c738 [file] [log] [blame]
/*
* Copyright (C) 2016 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License
*/
package com.android.providers.contacts.sqlite;
import android.annotation.Nullable;
import android.util.ArraySet;
import android.util.Log;
import com.android.providers.contacts.AbstractContactsProvider;
import com.google.common.annotations.VisibleForTesting;
import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Consumer;
/**
* Simple SQL validator to detect uses of hidden tables / columns as well as invalid SQLs.
*/
public class SqlChecker {
private static final String TAG = "SqlChecker";
private static final String PRIVATE_PREFIX = "x_"; // MUST BE LOWERCASE.
private static final boolean VERBOSE_LOGGING = AbstractContactsProvider.VERBOSE_LOGGING;
private final ArraySet<String> mInvalidTokens;
/**
* Create a new instance with given invalid tokens.
*/
public SqlChecker(List<String> invalidTokens) {
mInvalidTokens = new ArraySet<>(invalidTokens.size());
for (int i = invalidTokens.size() - 1; i >= 0; i--) {
mInvalidTokens.add(invalidTokens.get(i).toLowerCase());
}
if (VERBOSE_LOGGING) {
Log.d(TAG, "Initialized with invalid tokens: " + invalidTokens);
}
}
private static boolean isAlpha(char ch) {
return ('a' <= ch && ch <= 'z') || ('A' <= ch && ch <= 'Z') || (ch == '_');
}
private static boolean isNum(char ch) {
return ('0' <= ch && ch <= '9');
}
private static boolean isAlNum(char ch) {
return isAlpha(ch) || isNum(ch);
}
private static boolean isAnyOf(char ch, String set) {
return set.indexOf(ch) >= 0;
}
/**
* Exception for invalid queries.
*/
@VisibleForTesting
public static final class InvalidSqlException extends IllegalArgumentException {
public InvalidSqlException(String s) {
super(s);
}
}
private static InvalidSqlException genException(String message, String sql) {
throw new InvalidSqlException(message + " in '" + sql + "'");
}
private void throwIfContainsToken(String token, String sql) {
final String lower = token.toLowerCase();
if (mInvalidTokens.contains(lower) || lower.startsWith(PRIVATE_PREFIX)) {
throw genException("Detected disallowed token: " + token, sql);
}
}
/**
* Ensure {@code sql} is valid and doesn't contain invalid tokens.
*/
public void ensureNoInvalidTokens(@Nullable String sql) {
findTokens(sql, OPTION_NONE, token -> throwIfContainsToken(token, sql));
}
/**
* Ensure {@code sql} only contains a single, valid token. Use to validate column names
* in {@link android.content.ContentValues}.
*/
public void ensureSingleTokenOnly(@Nullable String sql) {
final AtomicBoolean tokenFound = new AtomicBoolean();
findTokens(sql, OPTION_TOKEN_ONLY, token -> {
if (tokenFound.get()) {
throw genException("Multiple tokens detected", sql);
}
tokenFound.set(true);
throwIfContainsToken(token, sql);
});
if (!tokenFound.get()) {
throw genException("Token not found", sql);
}
}
@VisibleForTesting
static final int OPTION_NONE = 0;
@VisibleForTesting
static final int OPTION_TOKEN_ONLY = 1 << 0;
private static char peek(String s, int index) {
return index < s.length() ? s.charAt(index) : '\0';
}
/**
* SQL Tokenizer specialized to extract tokens from SQL (snippets).
*
* Based on sqlite3GetToken() in tokenzie.c in SQLite.
*
* Source for v3.8.6 (which android uses): http://www.sqlite.org/src/artifact/ae45399d6252b4d7
* (Latest source as of now: http://www.sqlite.org/src/artifact/78c8085bc7af1922)
*
* Also draft spec: http://www.sqlite.org/draft/tokenreq.html
*/
@VisibleForTesting
static void findTokens(@Nullable String sql, int options, Consumer<String> checker) {
if (sql == null) {
return;
}
int pos = 0;
final int len = sql.length();
while (pos < len) {
final char ch = peek(sql, pos);
// Regular token.
if (isAlpha(ch)) {
final int start = pos;
pos++;
while (isAlNum(peek(sql, pos))) {
pos++;
}
final int end = pos;
final String token = sql.substring(start, end);
checker.accept(token);
continue;
}
// Handle quoted tokens
if (isAnyOf(ch, "'\"`")) {
final int quoteStart = pos;
pos++;
for (;;) {
pos = sql.indexOf(ch, pos);
if (pos < 0) {
throw genException("Unterminated quote", sql);
}
if (peek(sql, pos + 1) != ch) {
break;
}
// Quoted quote char -- e.g. "abc""def" is a single string.
pos += 2;
}
final int quoteEnd = pos;
pos++;
if (ch != '\'') {
// Extract the token
final String tokenUnquoted = sql.substring(quoteStart + 1, quoteEnd);
final String token;
// Unquote if needed. i.e. "aa""bb" -> aa"bb
if (tokenUnquoted.indexOf(ch) >= 0) {
token = tokenUnquoted.replaceAll(
String.valueOf(ch) + ch, String.valueOf(ch));
} else {
token = tokenUnquoted;
}
checker.accept(token);
} else {
if ((options &= OPTION_TOKEN_ONLY) != 0) {
throw genException("Non-token detected", sql);
}
}
continue;
}
// Handle tokens enclosed in [...]
if (ch == '[') {
final int quoteStart = pos;
pos++;
pos = sql.indexOf(']', pos);
if (pos < 0) {
throw genException("Unterminated quote", sql);
}
final int quoteEnd = pos;
pos++;
final String token = sql.substring(quoteStart + 1, quoteEnd);
checker.accept(token);
continue;
}
if ((options &= OPTION_TOKEN_ONLY) != 0) {
throw genException("Non-token detected", sql);
}
// Detect comments.
if (ch == '-' && peek(sql, pos + 1) == '-') {
pos += 2;
pos = sql.indexOf('\n', pos);
if (pos < 0) {
// We disallow strings ending in an inline comment.
throw genException("Unterminated comment", sql);
}
pos++;
continue;
}
if (ch == '/' && peek(sql, pos + 1) == '*') {
pos += 2;
pos = sql.indexOf("*/", pos);
if (pos < 0) {
throw genException("Unterminated comment", sql);
}
pos += 2;
continue;
}
// Semicolon is never allowed.
if (ch == ';') {
throw genException("Semicolon is not allowed", sql);
}
// For this purpose, we can simply ignore other characters.
// (Note it doesn't handle the X'' literal properly and reports this X as a token,
// but that should be fine...)
pos++;
}
}
}