diff --git a/src/androidTest/java/com/owncloud/android/providers/FileContentProviderVerificationIT.kt b/src/androidTest/java/com/owncloud/android/providers/FileContentProviderVerificationIT.kt new file mode 100644 index 0000000000..39f077098b --- /dev/null +++ b/src/androidTest/java/com/owncloud/android/providers/FileContentProviderVerificationIT.kt @@ -0,0 +1,115 @@ +/* + * + * Nextcloud Android client application + * + * @author Tobias Kaminsky + * Copyright (C) 2021 Tobias Kaminsky + * Copyright (C) 2021 Nextcloud GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package com.owncloud.android.providers + +import java.lang.IllegalArgumentException +import com.owncloud.android.db.ProviderMeta +import android.content.ContentValues +import com.owncloud.android.utils.MimeTypeUtil +import org.junit.Test + +@Suppress("FunctionNaming") +class FileContentProviderVerificationIT { + + companion object { + private const val INVALID_COLUMN = "Invalid column" + private const val FILE_LENGTH = 120 + } + + @Test(expected = IllegalArgumentException::class) + fun verifyColumnName_Exception() { + FileContentProvider.VerificationUtils.verifyColumnName(INVALID_COLUMN) + } + + @Test + fun verifyColumnName_OK() { + FileContentProvider.VerificationUtils.verifyColumnName(ProviderMeta.ProviderTableMeta.FILE_NAME) + } + + @Test + fun verifyColumn_ContentValues_OK() { + // with valid columns + val contentValues = ContentValues() + contentValues.put(ProviderMeta.ProviderTableMeta.FILE_CONTENT_LENGTH, FILE_LENGTH) + contentValues.put(ProviderMeta.ProviderTableMeta.FILE_CONTENT_TYPE, MimeTypeUtil.MIMETYPE_TEXT_MARKDOWN) + FileContentProvider.VerificationUtils.verifyColumns(contentValues) + + // empty + FileContentProvider.VerificationUtils.verifyColumns(ContentValues()) + } + + @Test(expected = IllegalArgumentException::class) + fun verifyColumn_ContentValues_invalidColumn() { + // with invalid columns + val contentValues = ContentValues() + contentValues.put(INVALID_COLUMN, FILE_LENGTH) + contentValues.put(ProviderMeta.ProviderTableMeta.FILE_CONTENT_TYPE, MimeTypeUtil.MIMETYPE_TEXT_MARKDOWN) + FileContentProvider.VerificationUtils.verifyColumns(contentValues) + } + + @Test + fun verifySortOrder_OK() { + // null + FileContentProvider.VerificationUtils.verifySortOrder(null) + + // empty + FileContentProvider.VerificationUtils.verifySortOrder("") + + // valid sort + FileContentProvider.VerificationUtils.verifySortOrder(ProviderMeta.ProviderTableMeta.FILE_DEFAULT_SORT_ORDER) + } + + @Test(expected = IllegalArgumentException::class) + fun verifySortOrder_InvalidColumn() { + // with invalid column + FileContentProvider.VerificationUtils.verifySortOrder("$INVALID_COLUMN desc") + } + + @Test(expected = IllegalArgumentException::class) + fun verifySortOrder_InvalidGrammar() { + // with invalid grammar + FileContentProvider.VerificationUtils.verifySortOrder("${ProviderMeta.ProviderTableMeta._ID} ;--foo") + } + + @Test + fun verifyWhere_OK() { + FileContentProvider.VerificationUtils.verifyWhere(null) + FileContentProvider.VerificationUtils.verifyWhere( + "${ProviderMeta.ProviderTableMeta._ID}=? AND ${ProviderMeta.ProviderTableMeta.FILE_ACCOUNT_OWNER}=?" + ) + FileContentProvider.VerificationUtils.verifyWhere( + "${ProviderMeta.ProviderTableMeta._ID} = 1" + + " AND (1 = 1)" + + " AND ${ProviderMeta.ProviderTableMeta.FILE_ACCOUNT_OWNER} LIKE ?" + ) + } + + @Test(expected = IllegalArgumentException::class) + fun verifyWhere_InvalidColumnName() { + FileContentProvider.VerificationUtils.verifyWhere("$INVALID_COLUMN= ?") + } + + @Test(expected = IllegalArgumentException::class) + fun verifyWhere_InvalidGrammar() { + FileContentProvider.VerificationUtils.verifyWhere("1=1 -- SELECT * FROM") + } +} diff --git a/src/main/java/com/owncloud/android/datamodel/ExternalLinksProvider.java b/src/main/java/com/owncloud/android/datamodel/ExternalLinksProvider.java index 83632db880..a5096d9188 100644 --- a/src/main/java/com/owncloud/android/datamodel/ExternalLinksProvider.java +++ b/src/main/java/com/owncloud/android/datamodel/ExternalLinksProvider.java @@ -76,7 +76,9 @@ public class ExternalLinksProvider { * @return numbers of rows deleted */ public int deleteAllExternalLinks() { - return mContentResolver.delete(ProviderMeta.ProviderTableMeta.CONTENT_URI_EXTERNAL_LINKS, " 1 = 1 ", null); + return mContentResolver.delete(ProviderMeta.ProviderTableMeta.CONTENT_URI_EXTERNAL_LINKS, + null, + null); } /** diff --git a/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java b/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java index 2c5dcf79cc..9c7a885140 100644 --- a/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java +++ b/src/main/java/com/owncloud/android/datamodel/FileDataStorageManager.java @@ -554,6 +554,10 @@ public class FileDataStorageManager { // ""+file.getFileId()); Uri file_uri = ContentUris.withAppendedId(ProviderTableMeta.CONTENT_URI_FILE, ocFile.getFileId()); String where = ProviderTableMeta.FILE_ACCOUNT_OWNER + AND + ProviderTableMeta.FILE_PATH + "=?"; + + // TODO switch to string array with fixed keys in it + // file_owner = ? and path =? + // String[] { "file_owner", "path"} String[] whereArgs = new String[]{user.getAccountName(), ocFile.getRemotePath()}; int deleted = 0; if (getContentProviderClient() != null) { @@ -905,7 +909,7 @@ public class FileDataStorageManager { ProviderTableMeta.FILE_PARENT + "=?", new String[]{String.valueOf(parentId)}, null - ); + ); } if (cursor != null) { diff --git a/src/main/java/com/owncloud/android/datamodel/SyncedFolderProvider.java b/src/main/java/com/owncloud/android/datamodel/SyncedFolderProvider.java index 84e58b5beb..7d6ec3cd1a 100644 --- a/src/main/java/com/owncloud/android/datamodel/SyncedFolderProvider.java +++ b/src/main/java/com/owncloud/android/datamodel/SyncedFolderProvider.java @@ -112,12 +112,12 @@ public class SyncedFolderProvider extends Observable { */ public List getSyncedFolders() { Cursor cursor = mContentResolver.query( - ProviderMeta.ProviderTableMeta.CONTENT_URI_SYNCED_FOLDERS, - null, - "1=1", - null, - null - ); + ProviderMeta.ProviderTableMeta.CONTENT_URI_SYNCED_FOLDERS, + null, + null, + null, + null + ); if (cursor != null) { List list = new ArrayList<>(cursor.getCount()); diff --git a/src/main/java/com/owncloud/android/db/ProviderMeta.java b/src/main/java/com/owncloud/android/db/ProviderMeta.java index 2746b24ab7..7657fb40c9 100644 --- a/src/main/java/com/owncloud/android/db/ProviderMeta.java +++ b/src/main/java/com/owncloud/android/db/ProviderMeta.java @@ -122,6 +122,7 @@ public class ProviderMeta { _ID, FILE_PARENT, FILE_NAME, + FILE_ENCRYPTED_NAME, FILE_CREATION, FILE_MODIFIED, FILE_MODIFIED_AT_LAST_SYNC_FOR_DATA, @@ -129,9 +130,11 @@ public class ProviderMeta { FILE_CONTENT_TYPE, FILE_STORAGE_PATH, FILE_PATH, + FILE_PATH_DECRYPTED, FILE_ACCOUNT_OWNER, FILE_LAST_SYNC_DATE, FILE_LAST_SYNC_DATE_FOR_DATA, + FILE_KEEP_IN_SYNC, FILE_ETAG, FILE_ETAG_ON_SERVER, FILE_SHARED_VIA_LINK, @@ -146,6 +149,9 @@ public class ProviderMeta { FILE_MOUNT_TYPE, FILE_HAS_PREVIEW, FILE_UNREAD_COMMENTS_COUNT, + FILE_OWNER_ID, + FILE_OWNER_DISPLAY_NAME, + FILE_NOTE, FILE_SHAREES, FILE_RICH_WORKSPACE)); diff --git a/src/main/java/com/owncloud/android/providers/FileContentProvider.java b/src/main/java/com/owncloud/android/providers/FileContentProvider.java index 57f0d7e8db..d8a4b592f0 100644 --- a/src/main/java/com/owncloud/android/providers/FileContentProvider.java +++ b/src/main/java/com/owncloud/android/providers/FileContentProvider.java @@ -64,7 +64,11 @@ import java.util.Locale; import javax.inject.Inject; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; import dagger.android.AndroidInjection; +import third_parties.aosp.SQLiteTokenizer; + /** * The ContentProvider for the ownCloud App. @@ -125,6 +129,14 @@ public class FileContentProvider extends ContentProvider { return -1; } + // verify where for public paths + switch (mUriMatcher.match(uri)) { + case ROOT_DIRECTORY: + case SINGLE_FILE: + case DIRECTORY: + VerificationUtils.verifyWhere(where); + } + int count; switch (mUriMatcher.match(uri)) { case SINGLE_FILE: @@ -169,7 +181,6 @@ public class FileContentProvider extends ContentProvider { private int deleteDirectory(SQLiteDatabase db, Uri uri, String where, String... whereArgs) { int count = 0; - Cursor children = query(uri, null, null, null, null); if (children != null) { if (children.moveToFirst()) { @@ -194,9 +205,7 @@ public class FileContentProvider extends ContentProvider { } if (uri.getPathSegments().size() > MINIMUM_PATH_SEGMENTS_SIZE) { - count += db.delete(ProviderTableMeta.FILE_TABLE_NAME, - ProviderTableMeta._ID + "=" + uri.getPathSegments().get(1) - + (!TextUtils.isEmpty(where) ? " AND (" + where + ")" : ""), whereArgs); + count += deleteWithuri(db, uri, where, whereArgs); } return count; @@ -215,9 +224,7 @@ public class FileContentProvider extends ContentProvider { if (remoteId == null) { return 0; } else { - count = db.delete(ProviderTableMeta.FILE_TABLE_NAME, - ProviderTableMeta._ID + "=" + uri.getPathSegments().get(1) - + (!TextUtils.isEmpty(where) ? " AND (" + where + ")" : ""), whereArgs); + count = deleteWithuri(db, uri, where, whereArgs); } } catch (Exception e) { Log_OC.d(TAG, "DB-Error removing file!", e); @@ -230,6 +237,13 @@ public class FileContentProvider extends ContentProvider { return count; } + private int deleteWithuri(SQLiteDatabase db, Uri uri, String where, String[] whereArgs) { + final String[] argsWithUri = VerificationUtils.prependUriFirstSegmentToSelectionArgs(whereArgs, uri); + return db.delete(ProviderTableMeta.FILE_TABLE_NAME, + ProviderTableMeta._ID + "=?" + + (!TextUtils.isEmpty(where) ? " AND (" + where + ")" : ""), argsWithUri); + } + @Override public String getType(@NonNull Uri uri) { switch (mUriMatcher.match(uri)) { @@ -262,6 +276,16 @@ public class FileContentProvider extends ContentProvider { } private Uri insert(SQLiteDatabase db, Uri uri, ContentValues values) { + // verify only for those requests that are not internal (files table) + switch (mUriMatcher.match(uri)) { + case ROOT_DIRECTORY: + case SINGLE_FILE: + case DIRECTORY: + VerificationUtils.verifyColumns(values); + break; + } + + switch (mUriMatcher.match(uri)) { case ROOT_DIRECTORY: case SINGLE_FILE: @@ -483,81 +507,66 @@ public class FileContentProvider extends ContentProvider { return result; } - private Cursor query(SQLiteDatabase db, Uri uri, String[] projectionArray, String selection, String[] selectionArgs, + private Cursor query(SQLiteDatabase db, + Uri uri, + String[] projectionArray, + String selection, + String[] selectionArgs, String sortOrder) { + // verify only for those requests that are not internal + final int uriMatch = mUriMatcher.match(uri); + SQLiteQueryBuilder sqlQuery = new SQLiteQueryBuilder(); - sqlQuery.setTables(ProviderTableMeta.FILE_TABLE_NAME); - switch (mUriMatcher.match(uri)) { + switch (uriMatch) { case ROOT_DIRECTORY: - break; case DIRECTORY: - if (uri.getPathSegments().size() > SINGLE_PATH_SEGMENT) { - sqlQuery.appendWhere(ProviderTableMeta.FILE_PARENT + "=" + uri.getPathSegments().get(1)); - } - break; case SINGLE_FILE: - if (uri.getPathSegments().size() > SINGLE_PATH_SEGMENT) { - sqlQuery.appendWhere(ProviderTableMeta._ID + "=" + uri.getPathSegments().get(1)); - } + VerificationUtils.verifyWhere(selection); // prevent injection in public paths + sqlQuery.setTables(ProviderTableMeta.FILE_TABLE_NAME); break; case SHARES: sqlQuery.setTables(ProviderTableMeta.OCSHARES_TABLE_NAME); - if (uri.getPathSegments().size() > SINGLE_PATH_SEGMENT) { - sqlQuery.appendWhere(ProviderTableMeta._ID + "=" + uri.getPathSegments().get(1)); - } break; case CAPABILITIES: sqlQuery.setTables(ProviderTableMeta.CAPABILITIES_TABLE_NAME); - if (uri.getPathSegments().size() > SINGLE_PATH_SEGMENT) { - sqlQuery.appendWhere(ProviderTableMeta._ID + "=" + uri.getPathSegments().get(1)); - } break; case UPLOADS: sqlQuery.setTables(ProviderTableMeta.UPLOADS_TABLE_NAME); - if (uri.getPathSegments().size() > SINGLE_PATH_SEGMENT) { - sqlQuery.appendWhere(ProviderTableMeta._ID + "=" + uri.getPathSegments().get(1)); - } break; case SYNCED_FOLDERS: sqlQuery.setTables(ProviderTableMeta.SYNCED_FOLDERS_TABLE_NAME); - if (uri.getPathSegments().size() > SINGLE_PATH_SEGMENT) { - sqlQuery.appendWhere(ProviderTableMeta._ID + "=" + uri.getPathSegments().get(1)); - } break; case EXTERNAL_LINKS: sqlQuery.setTables(ProviderTableMeta.EXTERNAL_LINKS_TABLE_NAME); - if (uri.getPathSegments().size() > SINGLE_PATH_SEGMENT) { - sqlQuery.appendWhere(ProviderTableMeta._ID + "=" + uri.getPathSegments().get(1)); - } break; case ARBITRARY_DATA: sqlQuery.setTables(ProviderTableMeta.ARBITRARY_DATA_TABLE_NAME); - if (uri.getPathSegments().size() > SINGLE_PATH_SEGMENT) { - sqlQuery.appendWhere(ProviderTableMeta._ID + "=" + uri.getPathSegments().get(1)); - } break; case VIRTUAL: sqlQuery.setTables(ProviderTableMeta.VIRTUAL_TABLE_NAME); - if (uri.getPathSegments().size() > SINGLE_PATH_SEGMENT) { - sqlQuery.appendWhere(ProviderTableMeta._ID + "=" + uri.getPathSegments().get(1)); - } break; case FILESYSTEM: sqlQuery.setTables(ProviderTableMeta.FILESYSTEM_TABLE_NAME); - if (uri.getPathSegments().size() > SINGLE_PATH_SEGMENT) { - sqlQuery.appendWhere(ProviderTableMeta._ID + "=" + uri.getPathSegments().get(1)); - } break; default: throw new IllegalArgumentException("Unknown uri id: " + uri); } + + // add ID to arguments if Uri has more than one segment + if (uriMatch != ROOT_DIRECTORY && uri.getPathSegments().size() > SINGLE_PATH_SEGMENT ) { + String idColumn = uriMatch == DIRECTORY ? ProviderTableMeta.FILE_PARENT : ProviderTableMeta._ID; + sqlQuery.appendWhere(idColumn + "=?"); + selectionArgs = VerificationUtils.prependUriFirstSegmentToSelectionArgs(selectionArgs, uri); + } + + String order; if (TextUtils.isEmpty(sortOrder)) { - switch (mUriMatcher.match(uri)) { + switch (uriMatch) { case SHARES: order = ProviderTableMeta.OCSHARES_DEFAULT_SORT_ORDER; break; @@ -587,6 +596,9 @@ public class FileContentProvider extends ContentProvider { break; } } else { + if (uriMatch == ROOT_DIRECTORY || uriMatch == SINGLE_FILE || uriMatch == DIRECTORY) { + VerificationUtils.verifySortOrder(sortOrder); + } order = sortOrder; } @@ -594,8 +606,8 @@ public class FileContentProvider extends ContentProvider { db.execSQL("PRAGMA case_sensitive_like = true"); // only file list is accessible via content provider, so only this has to be protected with projectionMap - if ((mUriMatcher.match(uri) == ROOT_DIRECTORY || mUriMatcher.match(uri) == SINGLE_FILE || - mUriMatcher.match(uri) == DIRECTORY) && projectionArray != null) { + if ((uriMatch == ROOT_DIRECTORY || uriMatch == SINGLE_FILE || + uriMatch == DIRECTORY) && projectionArray != null) { HashMap projectionMap = new HashMap<>(); for (String projection : ProviderTableMeta.FILE_ALL_COLUMNS) { @@ -637,6 +649,15 @@ public class FileContentProvider extends ContentProvider { } private int update(SQLiteDatabase db, Uri uri, ContentValues values, String selection, String... selectionArgs) { + // verify contentValues and selection for public paths to prevent injection + switch (mUriMatcher.match(uri)) { + case ROOT_DIRECTORY: + case SINGLE_FILE: + case DIRECTORY: + VerificationUtils.verifyColumns(values); + VerificationUtils.verifyWhere(selection); + } + switch (mUriMatcher.match(uri)) { case DIRECTORY: return 0; @@ -1034,6 +1055,106 @@ public class FileContentProvider extends ContentProvider { } } + + static class VerificationUtils { + + private static boolean isValidColumnName(@NonNull String columnName) { + return ProviderTableMeta.FILE_ALL_COLUMNS.contains(columnName); + } + + @VisibleForTesting + public static void verifyColumns(@Nullable ContentValues contentValues) { + if (contentValues == null || contentValues.keySet().size() == 0) { + return; + } + + for (String name : contentValues.keySet()) { + verifyColumnName(name); + } + } + + @VisibleForTesting + public static void verifyColumnName(@NonNull String columnName) { + if (!isValidColumnName(columnName)) { + throw new IllegalArgumentException(String.format("Column name \"%s\" is not allowed", columnName)); + } + } + + public static String[] prependUriFirstSegmentToSelectionArgs(@Nullable final String[] originalArgs, final Uri uri) { + String[] args; + if (originalArgs == null) { + args = new String[1]; + } else { + args = new String[originalArgs.length + 1]; + System.arraycopy(originalArgs, 0, args, 1, originalArgs.length); + } + args[0] = uri.getPathSegments().get(1); + return args; + } + + public static void verifySortOrder(@Nullable String sortOrder) { + if (sortOrder == null) { + return; + } + SQLiteTokenizer.tokenize(sortOrder, SQLiteTokenizer.OPTION_NONE, VerificationUtils::verifySortToken); + } + + private static void verifySortToken(String token){ + // accept empty tokens and valid column names + if (TextUtils.isEmpty(token) || isValidColumnName(token)) { + return; + } + // accept only a small subset of keywords + if(SQLiteTokenizer.isKeyword(token)){ + switch (token.toUpperCase(Locale.ROOT)) { + case "ASC": + case "DESC": + case "COLLATE": + case "NOCASE": + return; + } + } + // if none of the above, invalid token + throw new IllegalArgumentException("Invalid token " + token); + } + + public static void verifyWhere(@Nullable String where) { + if (where == null) { + return; + } + SQLiteTokenizer.tokenize(where, SQLiteTokenizer.OPTION_NONE, VerificationUtils::verifyWhereToken); + } + + private static void verifyWhereToken(String token) { + // allow empty, valid column names, functions (min,max,count) and types + if (TextUtils.isEmpty(token) || isValidColumnName(token) + || SQLiteTokenizer.isFunction(token) || SQLiteTokenizer.isType(token)) { + return; + } + + // Disallow dangerous keywords, allow others + if (SQLiteTokenizer.isKeyword(token)) { + switch (token.toUpperCase(Locale.ROOT)) { + case "SELECT": + case "FROM": + case "WHERE": + case "GROUP": + case "HAVING": + case "WINDOW": + case "VALUES": + case "ORDER": + case "LIMIT": + throw new IllegalArgumentException("Invalid token " + token); + default: + return; + } + } + + // if none of the above: invalid token + throw new IllegalArgumentException("Invalid token " + token); + } + } + class DataBaseHelper extends SQLiteOpenHelper { DataBaseHelper(Context context) { super(context, ProviderMeta.DB_NAME, null, ProviderMeta.DB_VERSION); diff --git a/src/main/java/third_parties/aosp/SQLiteTokenizer.java b/src/main/java/third_parties/aosp/SQLiteTokenizer.java new file mode 100644 index 0000000000..4b1728231e --- /dev/null +++ b/src/main/java/third_parties/aosp/SQLiteTokenizer.java @@ -0,0 +1,292 @@ +package third_parties.aosp; + +/* + * Copyright (C) 2019 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. + */ + + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.util.Consumer; + +/** + * SQL Tokenizer specialized to extract tokens from SQL (snippets). + * + * Originally from AOSP: https://github.com/aosp-mirror/platform_frameworks_base/blob/0e66ea6f3221aa8ccbb78ce38fbcaa67d8ea94f9/core/java/android/database/sqlite/SQLiteQueryBuilder.java + * Backported to be usable with AndroidX under api 24. + */ +public class SQLiteTokenizer { + 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; + } + + private static IllegalArgumentException genException(String message, String sql) { + throw new IllegalArgumentException(message + " in '" + sql + "'"); + } + + private static char peek(String s, int index) { + return index < s.length() ? s.charAt(index) : '\0'; + } + + public static final int OPTION_NONE = 0; + + /** + * Require that SQL contains only tokens; any comments or values will result + * in an exception. + */ + public static final int OPTION_TOKEN_ONLY = 1 << 0; + + /** + * Tokenize the given SQL, returning the list of each encountered token. + * + * @throws IllegalArgumentException if invalid SQL is encountered. + */ + public static List tokenize(@Nullable String sql, int options) { + final ArrayList res = new ArrayList<>(); + tokenize(sql, options, res::add); + return res; + } + + /** + * Tokenize the given SQL, sending each encountered token to the given + * {@link Consumer}. + * + * @throws IllegalArgumentException if invalid SQL is encountered. + */ + public static void tokenize(@Nullable String sql, int options, Consumer 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++; + } + } + + /** + * Test if given token is a + * SQLite reserved + * keyword. + */ + public static boolean isKeyword(@NonNull String token) { + switch (token.toUpperCase(Locale.US)) { + case "ABORT": case "ACTION": case "ADD": case "AFTER": + case "ALL": case "ALTER": case "ANALYZE": case "AND": + case "AS": case "ASC": case "ATTACH": case "AUTOINCREMENT": + case "BEFORE": case "BEGIN": case "BETWEEN": case "BINARY": + case "BY": case "CASCADE": case "CASE": case "CAST": + case "CHECK": case "COLLATE": case "COLUMN": case "COMMIT": + case "CONFLICT": case "CONSTRAINT": case "CREATE": case "CROSS": + case "CURRENT": case "CURRENT_DATE": case "CURRENT_TIME": case "CURRENT_TIMESTAMP": + case "DATABASE": case "DEFAULT": case "DEFERRABLE": case "DEFERRED": + case "DELETE": case "DESC": case "DETACH": case "DISTINCT": + case "DO": case "DROP": case "EACH": case "ELSE": + case "END": case "ESCAPE": case "EXCEPT": case "EXCLUDE": + case "EXCLUSIVE": case "EXISTS": case "EXPLAIN": case "FAIL": + case "FILTER": case "FOLLOWING": case "FOR": case "FOREIGN": + case "FROM": case "FULL": case "GLOB": case "GROUP": + case "GROUPS": case "HAVING": case "IF": case "IGNORE": + case "IMMEDIATE": case "IN": case "INDEX": case "INDEXED": + case "INITIALLY": case "INNER": case "INSERT": case "INSTEAD": + case "INTERSECT": case "INTO": case "IS": case "ISNULL": + case "JOIN": case "KEY": case "LEFT": case "LIKE": + case "LIMIT": case "MATCH": case "NATURAL": case "NO": + case "NOCASE": case "NOT": case "NOTHING": case "NOTNULL": + case "NULL": case "OF": case "OFFSET": case "ON": + case "OR": case "ORDER": case "OTHERS": case "OUTER": + case "OVER": case "PARTITION": case "PLAN": case "PRAGMA": + case "PRECEDING": case "PRIMARY": case "QUERY": case "RAISE": + case "RANGE": case "RECURSIVE": case "REFERENCES": case "REGEXP": + case "REINDEX": case "RELEASE": case "RENAME": case "REPLACE": + case "RESTRICT": case "RIGHT": case "ROLLBACK": case "ROW": + case "ROWS": case "RTRIM": case "SAVEPOINT": case "SELECT": + case "SET": case "TABLE": case "TEMP": case "TEMPORARY": + case "THEN": case "TIES": case "TO": case "TRANSACTION": + case "TRIGGER": case "UNBOUNDED": case "UNION": case "UNIQUE": + case "UPDATE": case "USING": case "VACUUM": case "VALUES": + case "VIEW": case "VIRTUAL": case "WHEN": case "WHERE": + case "WINDOW": case "WITH": case "WITHOUT": + return true; + default: + return false; + } + } + + /** + * Test if given token is a + * SQLite reserved + * function. + */ + public static boolean isFunction(@NonNull String token) { + switch (token.toLowerCase(Locale.US)) { + case "abs": case "avg": case "char": case "coalesce": + case "count": case "glob": case "group_concat": case "hex": + case "ifnull": case "instr": case "length": case "like": + case "likelihood": case "likely": case "lower": case "ltrim": + case "max": case "min": case "nullif": case "random": + case "randomblob": case "replace": case "round": case "rtrim": + case "substr": case "sum": case "total": case "trim": + case "typeof": case "unicode": case "unlikely": case "upper": + case "zeroblob": + return true; + default: + return false; + } + } + + /** + * Test if given token is a + * SQLite reserved type. + */ + public static boolean isType(@NonNull String token) { + switch (token.toUpperCase(Locale.US)) { + case "INT": case "INTEGER": case "TINYINT": case "SMALLINT": + case "MEDIUMINT": case "BIGINT": case "INT2": case "INT8": + case "CHARACTER": case "VARCHAR": case "NCHAR": case "NVARCHAR": + case "TEXT": case "CLOB": case "BLOB": case "REAL": + case "DOUBLE": case "FLOAT": case "NUMERIC": case "DECIMAL": + case "BOOLEAN": case "DATE": case "DATETIME": + return true; + default: + return false; + } + } +}