diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlite/src/main/java/ai/chat2db/plugin/sqlite/SqliteDBManager.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlite/src/main/java/ai/chat2db/plugin/sqlite/SqliteDBManager.java index d9d6329875..3f5069e8e4 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlite/src/main/java/ai/chat2db/plugin/sqlite/SqliteDBManager.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlite/src/main/java/ai/chat2db/plugin/sqlite/SqliteDBManager.java @@ -1,5 +1,6 @@ package ai.chat2db.plugin.sqlite; +import ai.chat2db.plugin.sqlite.identifier.SqliteIdentifierProcessor; import ai.chat2db.spi.IDbManager; import ai.chat2db.spi.DefaultDBManager; import ai.chat2db.community.domain.api.model.async.AsyncContext; @@ -42,7 +43,7 @@ private void exportTables(Connection connection, String databaseName, String sch public void exportTable(Connection connection, String databaseName, String schemaName, String tableName, AsyncContext asyncContext) throws SQLException { - String sql = String.format(SQL_SELECT_SQL_SQLITE_MASTER_TYPE, tableName); + String sql = String.format(SQL_SELECT_SQL_SQLITE_MASTER_TYPE, SqliteIdentifierProcessor.INSTANCE.escapeString(tableName)); try (PreparedStatement preparedStatement = connection.prepareStatement(sql); ResultSet resultSet = preparedStatement.executeQuery()) { if (resultSet.next()) { StringBuilder sqlBuilder = new StringBuilder(); @@ -57,7 +58,7 @@ public void exportTable(Connection connection, String databaseName, String schem } private String format(String tableName) { - return "\""+tableName+"\""; + return SqliteIdentifierProcessor.INSTANCE.quoteIdentifierAlways(tableName); } private void exportViews(Connection connection, String databaseName, AsyncContext asyncContext) throws SQLException { @@ -69,7 +70,7 @@ private void exportViews(Connection connection, String databaseName, AsyncContex } private void exportView(Connection connection, String viewName, AsyncContext asyncContext) throws SQLException { - String sql = String.format(SQL_SELECT_SQLITE_MASTER_TYPE_VIEW, viewName); + String sql = String.format(SQL_SELECT_SQLITE_MASTER_TYPE_VIEW, SqliteIdentifierProcessor.INSTANCE.escapeString(viewName)); try (PreparedStatement preparedStatement = connection.prepareStatement(sql); ResultSet resultSet = preparedStatement.executeQuery()) { if (resultSet.next()) { StringBuilder sqlBuilder = new StringBuilder(); @@ -91,7 +92,7 @@ private void exportTriggers(Connection connection, AsyncContext asyncContext) th } private void exportTrigger(Connection connection, String triggerName, AsyncContext asyncContext) throws SQLException { - String sql = String.format(SQL_SELECT_SQLITE_MASTER_TYPE_TRIGGER, triggerName); + String sql = String.format(SQL_SELECT_SQLITE_MASTER_TYPE_TRIGGER, SqliteIdentifierProcessor.INSTANCE.escapeString(triggerName)); try (PreparedStatement preparedStatement = connection.prepareStatement(sql); ResultSet resultSet = preparedStatement.executeQuery()) { if (resultSet.next()) { StringBuilder sqlBuilder = new StringBuilder(); diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlite/src/main/java/ai/chat2db/plugin/sqlite/SqliteMetaData.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlite/src/main/java/ai/chat2db/plugin/sqlite/SqliteMetaData.java index 1d7fd5d3a1..b087a80205 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlite/src/main/java/ai/chat2db/plugin/sqlite/SqliteMetaData.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlite/src/main/java/ai/chat2db/plugin/sqlite/SqliteMetaData.java @@ -35,12 +35,12 @@ public class SqliteMetaData extends DefaultMetaService implements IDbMetaData { - public static final ISQLIdentifierProcessor SQLITE_IDENTIFIER_PROCESSOR = new SqliteIdentifierProcessor(); + public static final ISQLIdentifierProcessor SQLITE_IDENTIFIER_PROCESSOR = SqliteIdentifierProcessor.INSTANCE; @Override public Table view(Connection connection, String databaseName, String schemaName, String viewName) { Table view = new Table(); - String sql = String.format(VIEW_DDL_SQL, viewName); + String sql = String.format(VIEW_DDL_SQL, getSQLIdentifierProcessor().escapeString(viewName)); DefaultSQLExecutor.getInstance().execute(connection, sql, resultSet -> { if (resultSet.next()) { view.setDatabaseName(databaseName); @@ -70,7 +70,7 @@ public List triggers(Connection connection, String databaseName, String @Override public Trigger trigger(Connection connection, String databaseName, String schemaName, String triggerName) { Trigger trigger = new Trigger(); - String sql = String.format(TRIGGER_DDL_SQL, triggerName); + String sql = String.format(TRIGGER_DDL_SQL, getSQLIdentifierProcessor().escapeString(triggerName)); return DefaultSQLExecutor.getInstance().execute(connection, sql, resultSet -> { while (resultSet.next()) { trigger.setTriggerName(triggerName); @@ -89,7 +89,7 @@ public List tables(Connection connection, String databaseName, String sch @Override public String tableDDL(Connection connection, String databaseName, String schemaName, String tableName) { - String sql = "SELECT sql FROM sqlite_master WHERE type='table' AND name='" + tableName + "'"; + String sql = "SELECT sql FROM sqlite_master WHERE type='table' AND name='" + getSQLIdentifierProcessor().escapeString(tableName) + "'"; return DefaultSQLExecutor.getInstance().execute(connection, sql, resultSet -> { try { if (resultSet.next()) { @@ -131,7 +131,9 @@ public TableMeta getTableMeta(String databaseName, String schemaName, String tab @Override public String getMetaDataName(String... names) { - return Arrays.stream(names).filter(name -> StringUtils.isNotBlank(name)).map(name -> "\"" + name + "\"").collect(Collectors.joining(".")); + return Arrays.stream(names).filter(StringUtils::isNotBlank) + .map(SqliteIdentifierProcessor.INSTANCE::quoteIdentifierAlways) + .collect(Collectors.joining(".")); } @Override diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlite/src/main/java/ai/chat2db/plugin/sqlite/SqliteSqlGuards.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlite/src/main/java/ai/chat2db/plugin/sqlite/SqliteSqlGuards.java new file mode 100644 index 0000000000..505347fd0c --- /dev/null +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlite/src/main/java/ai/chat2db/plugin/sqlite/SqliteSqlGuards.java @@ -0,0 +1,204 @@ +package ai.chat2db.plugin.sqlite; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.regex.Pattern; + +import ai.chat2db.plugin.sqlite.identifier.SqliteIdentifierProcessor; +import org.apache.commons.lang3.StringUtils; + +/** + * Validation helpers for non-escapable SQL positions in SQLite DDL generation + * (collation/charset keyword names, free-text column type names, column default + * expressions) and for {@code --} line comments. Escaping itself lives in + * {@link SqliteIdentifierProcessor}. + */ +public final class SqliteSqlGuards { + + /** + * Conservative allow-list for names embedded into keyword positions where quoting + * is not possible (COLLATE, CHARACTER SET). Accepts BINARY/NOCASE/RTRIM and + * simple custom collation names; rejects anything that could break out of the DDL. + */ + private static final Pattern SAFE_NAME = Pattern.compile("[A-Za-z0-9_]+"); + + /** + * Conservative allow-list for free-text column type names + * (e.g. {@code VARCHAR(255)}, {@code NUMERIC(10,2)}, {@code DOUBLE PRECISION}). + * Anything else is rejected so a hostile type cannot smuggle SQL into generated DDL. + */ + private static final Set COLUMN_CLAUSE_KEYWORDS = Set.of( + "AUTOINCREMENT", "CHECK", "COLLATE", "CONSTRAINT", "DEFAULT", "GENERATED", + "NOT", "NULL", "PRIMARY", "REFERENCES", "UNIQUE"); + private static final Set STATEMENT_KEYWORDS = Set.of( + "ALTER", "ATTACH", "CREATE", "DELETE", "DETACH", "DROP", "INSERT", "PRAGMA", + "REINDEX", "REPLACE", "SELECT", "UPDATE", "VACUUM"); + + private SqliteSqlGuards() { + } + + /** + * Validates a name embedded into a keyword position (collation, charset) against a + * conservative allow-list. Returns the name unchanged when safe; throws otherwise + * (fail closed). + * + * @throws IllegalArgumentException if the name contains unexpected characters + */ + public static String requireSafeName(String name, String what) { + if (name == null || !SAFE_NAME.matcher(name).matches()) { + throw new IllegalArgumentException("Unsafe " + what + " name: " + name); + } + return name; + } + + /** + * Validates a free-text column type name before it is embedded into generated DDL. + * Returns the type name unchanged when it matches a conservative allow-list; + * throws otherwise (fail closed). + * + * @throws IllegalArgumentException if the type name contains unexpected characters + */ + public static String requireSafeTypeName(String typeName) { + String expression = StringUtils.trimToNull(typeName); + if (expression == null) { + return null; + } + scanExpression(expression, true, "column type"); + return expression; + } + + /** + * Validates one SQLite DEFAULT expression while preserving its SQL semantics. Nested + * functions and quoted literals are accepted when delimiters are balanced; comments, + * statement boundaries, top-level commas, and appended column constraints are rejected. + * Returns an empty string for {@code null}. + */ + public static String escapeColumnDefault(String columnDefault) { + if (columnDefault == null) { + return ""; + } + String trimmed = columnDefault.trim(); + if (trimmed.isEmpty()) { + throw invalid("DEFAULT expression", columnDefault); + } + if ("NULL".equalsIgnoreCase(trimmed)) { + return trimmed; + } + scanExpression(trimmed, false, "DEFAULT expression"); + return trimmed; + } + + /** + * Neutralises a comment embedded after a {@code --} line-comment marker by + * flattening CR/LF to spaces, so the comment cannot break out onto a new, + * executable line. Returns an empty string for {@code null}. + */ + public static String sanitizeLineComment(String comment) { + if (comment == null) { + return ""; + } + return StringUtils.replaceEach(comment, + new String[]{"\r", "\n", "\u0085", "\u2028", "\u2029"}, + new String[]{" ", " ", " ", " ", " "}); + } + + private static void scanExpression(String expression, boolean typeExpression, String description) { + Deque parentheses = new ArrayDeque<>(); + List topLevelWords = new ArrayList<>(); + boolean sawToken = false; + + for (int i = 0; i < expression.length(); i++) { + char c = expression.charAt(i); + if (Character.isWhitespace(c)) { + continue; + } + sawToken = true; + if (c == '\'' || c == '"') { + if (typeExpression && c == '\'') { + throw invalid(description, expression); + } + i = scanQuoted(expression, i, c, description); + continue; + } + if (c == ';' || Character.isISOControl(c) + || startsWith(expression, i, "--") + || startsWith(expression, i, "/*") + || startsWith(expression, i, "*/")) { + throw invalid(description, expression); + } + if (c == '(') { + parentheses.push(c); + continue; + } + if (c == ')') { + if (parentheses.isEmpty()) { + throw invalid(description, expression); + } + parentheses.pop(); + continue; + } + if (c == ',' && parentheses.isEmpty()) { + throw invalid(description, expression); + } + if (typeExpression && !isTypeCharacter(c, !parentheses.isEmpty())) { + throw invalid(description, expression); + } + if (Character.isLetter(c) || c == '_') { + int wordEnd = i + 1; + while (wordEnd < expression.length() && isWordCharacter(expression.charAt(wordEnd))) { + wordEnd++; + } + String word = expression.substring(i, wordEnd).toUpperCase(Locale.ROOT); + if (parentheses.isEmpty()) { + topLevelWords.add(word); + } + i = wordEnd - 1; + } + } + if (!sawToken || !parentheses.isEmpty()) { + throw invalid(description, expression); + } + for (String word : topLevelWords) { + if (STATEMENT_KEYWORDS.contains(word) + || COLUMN_CLAUSE_KEYWORDS.contains(word) + || typeExpression && "NULL".equals(word)) { + throw invalid(description, expression); + } + } + } + + private static int scanQuoted(String expression, int start, char quote, String description) { + for (int i = start + 1; i < expression.length(); i++) { + if (expression.charAt(i) == quote) { + if (i + 1 < expression.length() && expression.charAt(i + 1) == quote) { + i++; + continue; + } + return i; + } + } + throw invalid(description, expression); + } + + private static boolean isTypeCharacter(char c, boolean insideParentheses) { + return Character.isLetterOrDigit(c) || c == '_' || c == '$' || c == '.' + || c == ',' && insideParentheses + || (c == '+' || c == '-') && insideParentheses; + } + + private static boolean isWordCharacter(char c) { + return Character.isLetterOrDigit(c) || c == '_' || c == '$'; + } + + private static boolean startsWith(String value, int offset, String candidate) { + return offset + candidate.length() <= value.length() && value.startsWith(candidate, offset); + } + + private static IllegalArgumentException invalid(String description, String value) { + return new IllegalArgumentException("Invalid SQLite " + description + ": " + value); + } +} diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlite/src/main/java/ai/chat2db/plugin/sqlite/builder/SqliteBuilder.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlite/src/main/java/ai/chat2db/plugin/sqlite/builder/SqliteBuilder.java index 80b95c477e..792ea15130 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlite/src/main/java/ai/chat2db/plugin/sqlite/builder/SqliteBuilder.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlite/src/main/java/ai/chat2db/plugin/sqlite/builder/SqliteBuilder.java @@ -2,36 +2,63 @@ import ai.chat2db.spi.constant.SQLConstants; -import ai.chat2db.plugin.sqlite.SqliteMetaData; +import ai.chat2db.community.domain.api.enums.plugin.DmlTypeEnum; +import ai.chat2db.plugin.sqlite.SqliteSqlGuards; +import ai.chat2db.plugin.sqlite.identifier.SqliteIdentifierProcessor; import ai.chat2db.plugin.sqlite.enums.type.SqliteColumnTypeEnum; import ai.chat2db.plugin.sqlite.enums.type.SqliteIndexTypeEnum; -import ai.chat2db.spi.ISQLIdentifierProcessor; import ai.chat2db.spi.DefaultSqlBuilder; import ai.chat2db.spi.model.request.PageLimitRequest; +import ai.chat2db.spi.model.request.UpdateSqlRequest; import ai.chat2db.community.domain.api.model.metadata.Table; import ai.chat2db.community.domain.api.model.metadata.TableColumn; import ai.chat2db.community.domain.api.model.metadata.TableIndex; import ai.chat2db.community.domain.api.config.TableBuilderConfig; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.collections4.MapUtils; import org.apache.commons.lang3.StringUtils; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; import static ai.chat2db.plugin.sqlite.constant.SqliteBuilderConstants.*; public class SqliteBuilder extends DefaultSqlBuilder { + @Override + public String quoteIdentifier(String identifier) { + return SqliteIdentifierProcessor.INSTANCE.quoteIdentifierAlways(identifier); + } + @Override + public String quoteQualifiedIdentifier(String... identifiers) { + return Arrays.stream(identifiers) + .filter(StringUtils::isNotBlank) + .map(SqliteIdentifierProcessor.INSTANCE::quoteIdentifierAlways) + .collect(Collectors.joining(SQLConstants.DOT)); + } + @Override + protected void buildTableName(String databaseName, String schemaName, String tableName, StringBuilder script) { + script.append(quoteQualifiedIdentifier(databaseName, schemaName, tableName)); + } - - - + @Override + protected void buildColumns(List columnList, StringBuilder script) { + if (CollectionUtils.isNotEmpty(columnList)) { + script.append(SQLConstants.SPACE_OPEN_PARENTHESIS) + .append(columnList.stream().map(this::quoteIdentifier) + .collect(Collectors.joining(SQLConstants.COMMA))) + .append(SQLConstants.CLOSE_PARENTHESIS_SPACE); + } + } @Override public String buildCreateTable(Table table, TableBuilderConfig tableBuilderConfig) { StringBuilder script = new StringBuilder(); script.append(SQL_CREATE_TABLE); - if (StringUtils.isNotBlank(table.getDatabaseName())) { - script.append(SQLConstants.DOUBLE_QUOTE).append(table.getDatabaseName()).append(SQLConstants.DOUBLE_QUOTE_DOT_DOUBLE_QUOTE); - } - script.append(SQLConstants.DOUBLE_QUOTE).append(table.getName()).append(SQLConstants.DOUBLE_QUOTE).append(SQLConstants.SPACE_OPEN_PARENTHESIS).append(SQLConstants.LINE_SEPARATOR); + script.append(quoteQualifiedIdentifier(table.getDatabaseName(), table.getName())) + .append(SQLConstants.SPACE_OPEN_PARENTHESIS).append(SQLConstants.LINE_SEPARATOR); for (TableColumn column : table.getColumnList()) { if (StringUtils.isBlank(column.getName()) || StringUtils.isBlank(column.getColumnType())) { continue; @@ -40,13 +67,13 @@ public String buildCreateTable(Table table, TableBuilderConfig tableBuilderConfi if (typeEnum == null) { script.append(SQLConstants.TAB).append(buildDefaultCreateColumnSql(column)).append(SQLConstants.COMMA); if (StringUtils.isNotBlank(column.getComment())) { - script.append(VALUE).append(column.getComment()).append(SQLConstants.SPACE); + script.append(VALUE).append(SqliteSqlGuards.sanitizeLineComment(column.getComment())).append(SQLConstants.SPACE); } script.append(SQLConstants.LINE_SEPARATOR); } else { script.append(SQLConstants.TAB).append(typeEnum.buildCreateColumnSql(column)).append(SQLConstants.COMMA); if (StringUtils.isNotBlank(column.getComment())) { - script.append(VALUE).append(column.getComment()).append(SQLConstants.SPACE); + script.append(VALUE).append(SqliteSqlGuards.sanitizeLineComment(column.getComment())).append(SQLConstants.SPACE); } script.append(SQLConstants.LINE_SEPARATOR); } @@ -79,8 +106,8 @@ public String buildCreateTable(Table table, TableBuilderConfig tableBuilderConfi public String buildDefaultCreateColumnSql(TableColumn column) { StringBuilder script = new StringBuilder(); - script.append(SQLConstants.DOUBLE_QUOTE).append(column.getName()).append(SQLConstants.DOUBLE_QUOTE).append(SQLConstants.SPACE); - script.append(column.getColumnType()).append(SQLConstants.SPACE); + script.append(quoteIdentifier(column.getName())).append(SQLConstants.SPACE); + script.append(SqliteSqlGuards.requireSafeTypeName(column.getColumnType())).append(SQLConstants.SPACE); return script.toString(); } @@ -89,12 +116,17 @@ public String buildDefaultCreateColumnSql(TableColumn column) { public String buildAlterTable(Table oldTable, Table newTable) { StringBuilder script = new StringBuilder(); if (!StringUtils.equalsIgnoreCase(oldTable.getName(), newTable.getName())) { - script.append(SQL_ALTER_TABLE).append(SQLConstants.DOUBLE_QUOTE).append(oldTable.getDatabaseName()).append(SQLConstants.DOUBLE_QUOTE_DOT_DOUBLE_QUOTE).append(oldTable.getName()).append(SQLConstants.DOUBLE_QUOTE).append(SQLConstants.LINE_SEPARATOR); - script.append(SQLConstants.TAB).append(SQL_RENAME).append(SQLConstants.DOUBLE_QUOTE).append(newTable.getName()).append(SQLConstants.DOUBLE_QUOTE).append(SQLConstants.SEMICOLON_LINE_SEPARATOR); + script.append(SQL_ALTER_TABLE) + .append(quoteQualifiedIdentifier(oldTable.getDatabaseName(), oldTable.getName())) + .append(SQLConstants.LINE_SEPARATOR); + script.append(SQLConstants.TAB).append(SQL_RENAME).append(quoteIdentifier(newTable.getName())) + .append(SQLConstants.SEMICOLON_LINE_SEPARATOR); } for (TableColumn tableColumn : newTable.getColumnList()) { if (StringUtils.isNotBlank(tableColumn.getEditStatus()) && StringUtils.isNotBlank(tableColumn.getColumnType()) && StringUtils.isNotBlank(tableColumn.getName())) { - script.append(SQL_ALTER_TABLE).append(SQLConstants.DOUBLE_QUOTE).append(newTable.getDatabaseName()).append(SQLConstants.DOUBLE_QUOTE_DOT_DOUBLE_QUOTE).append(newTable.getName()).append(SQLConstants.DOUBLE_QUOTE).append(SQLConstants.LINE_SEPARATOR); + script.append(SQL_ALTER_TABLE) + .append(quoteQualifiedIdentifier(newTable.getDatabaseName(), newTable.getName())) + .append(SQLConstants.LINE_SEPARATOR); SqliteColumnTypeEnum typeEnum = SqliteColumnTypeEnum.getByType(tableColumn.getColumnType()); if (typeEnum == null) { continue; @@ -130,11 +162,54 @@ public String buildPageLimit(PageLimitRequest request) { } @Override - protected void buildTableName(String databaseName, String schemaName, String tableName, StringBuilder script) { - ISQLIdentifierProcessor sqliteIdentifierProcessor = SqliteMetaData.SQLITE_IDENTIFIER_PROCESSOR; - if (StringUtils.isNotBlank(databaseName)) { - script.append(sqliteIdentifierProcessor.quoteIdentifier(databaseName)).append('.'); + public String buildUpdate(UpdateSqlRequest request) { + Map row = request.getRow(); + Map primaryKeyMap = request.getPrimaryKeyMap(); + StringBuilder script = new StringBuilder(SQLConstants.UPDATE_SQL_PREFIX); + buildTableName(request.getDatabaseName(), request.getSchemaName(), request.getTableName(), script); + script.append(SQLConstants.SET_SQL); + script.append(row.entrySet().stream() + .map(entry -> quoteIdentifier(entry.getKey()) + SQLConstants.EQUAL_SQL + entry.getValue()) + .collect(Collectors.joining(SQLConstants.COMMA))); + if (MapUtils.isNotEmpty(primaryKeyMap)) { + script.append(SQLConstants.WHERE_SQL); + script.append(primaryKeyMap.entrySet().stream() + .map(entry -> quoteIdentifier(entry.getKey()) + SQLConstants.EQUAL_SQL + entry.getValue()) + .collect(Collectors.joining(SQLConstants.SQL_AND))); + } + return script.toString(); + } + + @Override + public String buildTemplate(Table table, String type) { + if (table == null || CollectionUtils.isEmpty(table.getColumnList()) || StringUtils.isBlank(type)) { + return SQLConstants.EMPTY; + } + String tableName = quoteIdentifier(table.getName()); + List columnNames = table.getColumnList().stream() + .map(column -> quoteIdentifier(column.getName())) + .collect(Collectors.toList()); + if (DmlTypeEnum.INSERT.name().equalsIgnoreCase(type)) { + return SQLConstants.INSERT_INTO_SQL_PREFIX + tableName + SQLConstants.SPACE_OPEN_PARENTHESIS + + String.join(SQLConstants.COMMA, columnNames) + + SQLConstants.CLOSE_PARENTHESIS + SQLConstants.VALUES_SQL + SQLConstants.OPEN_PARENTHESIS + + columnNames.stream().map(name -> SQLConstants.SPACE).collect(Collectors.joining(SQLConstants.COMMA)) + + SQLConstants.CLOSE_PARENTHESIS; + } + if (DmlTypeEnum.UPDATE.name().equalsIgnoreCase(type)) { + String setClause = columnNames.stream() + .map(name -> name + SQLConstants.EQUAL_SQL + SQLConstants.SPACE) + .collect(Collectors.joining(SQLConstants.COMMA)); + return SQLConstants.UPDATE_SQL_PREFIX + tableName + SQLConstants.SET_SQL_LOWER + + setClause + SQLConstants.WHERE_SQL_LOWER; + } + if (DmlTypeEnum.DELETE.name().equalsIgnoreCase(type)) { + return SQLConstants.DELETE_FROM_SQL_PREFIX + tableName + SQLConstants.WHERE_SQL_LOWER; + } + if (DmlTypeEnum.SELECT.name().equalsIgnoreCase(type)) { + return SQLConstants.SELECT_SQL_PREFIX + String.join(SQLConstants.COMMA, columnNames) + + " FROM " + tableName; } - script.append(sqliteIdentifierProcessor.quoteIdentifier(tableName)); + return SQLConstants.EMPTY; } } diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlite/src/main/java/ai/chat2db/plugin/sqlite/constant/SqliteIndexTypeEnumConstants.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlite/src/main/java/ai/chat2db/plugin/sqlite/constant/SqliteIndexTypeEnumConstants.java index c6047746da..50b9fab59b 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlite/src/main/java/ai/chat2db/plugin/sqlite/constant/SqliteIndexTypeEnumConstants.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlite/src/main/java/ai/chat2db/plugin/sqlite/constant/SqliteIndexTypeEnumConstants.java @@ -14,7 +14,7 @@ public final class SqliteIndexTypeEnumConstants { public static final String SQL_COMMENT = "COMMENT '"; public static final String SQL_CREATE = "CREATE "; - public static final String SQL_DROP_INDEX = "DROP INDEX \""; + public static final String SQL_DROP_INDEX = "DROP INDEX "; public static final String SQL_DROP_PRIMARY_KEY = "DROP PRIMARY KEY"; public static final String SQL_ON = " ON "; diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlite/src/main/java/ai/chat2db/plugin/sqlite/enums/type/SqliteColumnTypeEnum.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlite/src/main/java/ai/chat2db/plugin/sqlite/enums/type/SqliteColumnTypeEnum.java index 548967e34f..85f3debb97 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlite/src/main/java/ai/chat2db/plugin/sqlite/enums/type/SqliteColumnTypeEnum.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlite/src/main/java/ai/chat2db/plugin/sqlite/enums/type/SqliteColumnTypeEnum.java @@ -1,5 +1,7 @@ package ai.chat2db.plugin.sqlite.enums.type; +import ai.chat2db.plugin.sqlite.SqliteSqlGuards; +import ai.chat2db.plugin.sqlite.identifier.SqliteIdentifierProcessor; import ai.chat2db.spi.IColumnBuilder; import ai.chat2db.community.domain.api.enums.plugin.EditStatusEnum; import ai.chat2db.community.domain.api.model.metadata.ColumnType; @@ -10,25 +12,26 @@ import java.util.Arrays; import java.util.List; +import java.util.Locale; import java.util.Map; public enum SqliteColumnTypeEnum implements IColumnBuilder { - INTEGER("INTEGER", true, false, true, false, false, true, false, false, false, false), + INTEGER("INTEGER", true, false, true, false, false, true, false, true, false, false), - REAL("REAL", true, false, true, false, false, true, false, false, false, false), + REAL("REAL", true, false, true, false, false, true, false, true, false, false), - BLOB("BLOB", true, false, true, false, false, true, false, false, false, false), + BLOB("BLOB", true, false, true, false, false, true, false, true, false, false), - TEXT("TEXT", true, false, true, false, false, true, false, false, false, false), + TEXT("TEXT", true, false, true, false, false, true, false, true, false, false), ; private ColumnType columnType; public static SqliteColumnTypeEnum getByType(String dataType) { - return COLUMN_TYPE_MAP.get(SqlUtils.removeDigits(dataType.toUpperCase())); + return dataType == null ? null : COLUMN_TYPE_MAP.get(SqlUtils.removeDigits(dataType.toUpperCase(Locale.ROOT))); } public ColumnType getColumnType() { @@ -51,13 +54,14 @@ public ColumnType getColumnType() { @Override public String buildCreateColumnSql(TableColumn column) { - SqliteColumnTypeEnum type = COLUMN_TYPE_MAP.get(column.getColumnType().toUpperCase()); + SqliteColumnTypeEnum type = column.getColumnType() == null ? null + : COLUMN_TYPE_MAP.get(column.getColumnType().toUpperCase(Locale.ROOT)); if (type == null) { return buildDefaultColumn(column, false); } StringBuilder script = new StringBuilder(); - script.append("\"").append(column.getName()).append("\"").append(" "); + script.append(SqliteIdentifierProcessor.INSTANCE.quoteIdentifierAlways(column.getName())).append(" "); script.append(buildDataType(column, type)).append(" "); @@ -80,14 +84,14 @@ private String buildCharset(TableColumn column, SqliteColumnTypeEnum type) { if (!type.getColumnType().isSupportCharset() || StringUtils.isEmpty(column.getCharSetName())) { return ""; } - return StringUtils.join("CHARACTER SET ", column.getCharSetName()); + return StringUtils.join("CHARACTER SET ", SqliteSqlGuards.requireSafeName(column.getCharSetName(), "charset")); } private String buildCollation(TableColumn column, SqliteColumnTypeEnum type) { if (!type.getColumnType().isSupportCollation() || StringUtils.isEmpty(column.getCollationName())) { return ""; } - return StringUtils.join("COLLATE ", column.getCollationName()); + return StringUtils.join("COLLATE ", SqliteSqlGuards.requireSafeName(column.getCollationName(), "collation")); } @Override @@ -129,7 +133,7 @@ private String buildDefaultValue(TableColumn column, SqliteColumnTypeEnum type) return StringUtils.join("DEFAULT NULL"); } - return StringUtils.join("DEFAULT ", column.getDefaultValue()); + return StringUtils.join("DEFAULT ", SqliteSqlGuards.escapeColumnDefault(column.getDefaultValue())); } private String buildNullable(TableColumn column, SqliteColumnTypeEnum type) { diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlite/src/main/java/ai/chat2db/plugin/sqlite/enums/type/SqliteIndexTypeEnum.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlite/src/main/java/ai/chat2db/plugin/sqlite/enums/type/SqliteIndexTypeEnum.java index 8f3c61f246..443ed5c6d9 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlite/src/main/java/ai/chat2db/plugin/sqlite/enums/type/SqliteIndexTypeEnum.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlite/src/main/java/ai/chat2db/plugin/sqlite/enums/type/SqliteIndexTypeEnum.java @@ -4,6 +4,7 @@ import ai.chat2db.community.domain.api.model.metadata.IndexType; import ai.chat2db.community.domain.api.model.metadata.TableIndex; import ai.chat2db.community.domain.api.model.metadata.TableIndexColumn; +import ai.chat2db.plugin.sqlite.identifier.SqliteIdentifierProcessor; import org.apache.commons.lang3.StringUtils; import java.util.Arrays; @@ -72,7 +73,7 @@ public String buildIndexScript(TableIndex tableIndex) { script.append(keyword).append(" "); - script.append(buildIndexName(tableIndex)).append(SQL_ON).append(tableIndex.getTableName()).append(" "); + script.append(buildIndexName(tableIndex)).append(SQL_ON).append(quoteName(tableIndex.getTableName())).append(" "); script.append(buildIndexColumn(tableIndex)).append(" "); return script.toString(); @@ -101,9 +102,12 @@ private String buildIndexColumn(TableIndex tableIndex) { script.append("("); for (TableIndexColumn column : tableIndex.getColumnList()) { if (StringUtils.isNotBlank(column.getColumnName())) { - script.append("\"").append(column.getColumnName()).append("\"").append(","); + script.append(quoteName(column.getColumnName())).append(","); } } + if (script.length() == 1) { + throw new IllegalArgumentException("SQLite index must contain at least one named column"); + } script.deleteCharAt(script.length() - 1); script.append(")"); return script.toString(); @@ -111,12 +115,16 @@ private String buildIndexColumn(TableIndex tableIndex) { private String buildIndexName(TableIndex tableIndex) { if (this.equals(PRIMARY_KEY)) { - return tableIndex.getTableName()+"_pk"; + return quoteName(tableIndex.getTableName() + "_pk"); } else { - return "\"" + tableIndex.getName() + "\""; + return quoteName(tableIndex.getName()); } } + private static String quoteName(String name) { + return SqliteIdentifierProcessor.INSTANCE.quoteIdentifierAlways(name); + } + public String buildModifyIndex(TableIndex tableIndex) { if (EditStatusEnum.DELETE.name().equals(tableIndex.getEditStatus())) { return buildDropIndex(tableIndex); @@ -134,7 +142,7 @@ private String buildDropIndex(TableIndex tableIndex) { if (SqliteIndexTypeEnum.PRIMARY_KEY.getName().equals(tableIndex.getType())) { return StringUtils.join(SQL_DROP_PRIMARY_KEY); } - return StringUtils.join(SQL_DROP_INDEX, tableIndex.getOldName(), "\""); + return StringUtils.join(SQL_DROP_INDEX, quoteName(tableIndex.getOldName())); } public static List getIndexTypes() { diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlite/src/main/java/ai/chat2db/plugin/sqlite/identifier/SqliteIdentifierProcessor.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlite/src/main/java/ai/chat2db/plugin/sqlite/identifier/SqliteIdentifierProcessor.java index 646a9f832b..f3070ae04f 100644 --- a/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlite/src/main/java/ai/chat2db/plugin/sqlite/identifier/SqliteIdentifierProcessor.java +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlite/src/main/java/ai/chat2db/plugin/sqlite/identifier/SqliteIdentifierProcessor.java @@ -1,7 +1,111 @@ package ai.chat2db.plugin.sqlite.identifier; import ai.chat2db.spi.DefaultSQLIdentifierProcessor; +import org.apache.commons.lang3.StringUtils; +import java.util.Arrays; +import java.util.Locale; +import java.util.Set; + +/** + * SQLite dialect identifier processor: double-quoted identifiers with embedded-quote + * doubling, and single-quote doubling for string literals. Identifiers that are + * already valid pass through unquoted. Shared stateless instance available via + * {@link #INSTANCE} for call sites without MetaData access. + */ public class SqliteIdentifierProcessor extends DefaultSQLIdentifierProcessor { + public static final SqliteIdentifierProcessor INSTANCE = new SqliteIdentifierProcessor(); + + private static final Set RESERVED_KEYWORDS = Set.copyOf(Arrays.asList(( + "ABORT ACTION ADD AFTER ALL ALTER ANALYZE AND AS ASC ATTACH AUTOINCREMENT BEFORE BEGIN " + + "BETWEEN BY CASCADE CASE CAST CHECK COLLATE COLUMN COMMIT CONFLICT CONSTRAINT CREATE CROSS " + + "CURRENT CURRENT_DATE CURRENT_TIME CURRENT_TIMESTAMP DATABASE DEFAULT DEFERRABLE DEFERRED " + + "DELETE DESC DETACH DISTINCT DO DROP EACH ELSE END ESCAPE EXCEPT EXCLUDE EXCLUSIVE EXISTS " + + "EXPLAIN FAIL FILTER FIRST FOLLOWING FOR FOREIGN FROM FULL GENERATED GLOB GROUP GROUPS " + + "HAVING IF IGNORE IMMEDIATE IN INDEX INDEXED INITIALLY INNER INSERT INSTEAD INTERSECT INTO " + + "IS ISNULL JOIN KEY LAST LEFT LIKE LIMIT MATCH MATERIALIZED NATURAL NO NOT NOTHING NOTNULL " + + "NULL NULLS OF OFFSET ON OR ORDER OTHERS OUTER OVER PARTITION PLAN PRAGMA PRECEDING PRIMARY " + + "QUERY RAISE RANGE RECURSIVE REFERENCES REGEXP REINDEX RELEASE RENAME REPLACE RESTRICT " + + "RETURNING RIGHT ROLLBACK ROW ROWS SAVEPOINT SELECT SET TABLE TEMP TEMPORARY THEN TIES TO " + + "TRANSACTION TRIGGER UNBOUNDED UNION UNIQUE UPDATE USING VACUUM VALUES VIEW VIRTUAL WHEN " + + "WHERE WINDOW WITH WITHOUT").split("\\s+"))); + + @Override + public boolean isReservedKeyword(String identifier, Integer majorVersion, Integer minorVersion) { + return identifier != null && RESERVED_KEYWORDS.contains(identifier.toUpperCase(Locale.ROOT)); + } + + /** + * Valid non-keyword identifiers pass through unchanged; already valid quoted + * identifiers are preserved; anything else is quoted safely. + */ + @Override + public String quoteIdentifier(String identifier) { + if (identifier == null || StringUtils.isBlank(identifier)) { + return identifier; + } + if (isValidQuotedIdentifier(identifier)) { + return identifier; + } + if (isValidIdentifier(identifier) && !isReservedKeyword(identifier, null, null)) { + return identifier; + } + return quoteIdentifierAlways(identifier); + } + + @Override + public String quoteIdentifier(String identifier, Integer majorVersion, Integer minorVersion) { + return quoteIdentifier(identifier); + } + + @Override + public String quoteIdentifierIgnoreCase(String identifier) { + return quoteIdentifier(identifier); + } + + @Override + public String quoteIdentifierAlways(String identifier) { + if (identifier == null) { + return null; + } + return "\"" + escapeIdentifierContent(identifier) + "\""; + } + + /** + * Escapes a value interpolated into a single-quoted SQL string literal by + * doubling every single quote. Preserves {@code null}. + */ + @Override + public String escapeString(String str) { + return str == null ? null : StringUtils.replace(str, "'", "''"); + } + + private static String escapeIdentifierContent(String identifier) { + return identifier == null ? null : StringUtils.replace(identifier, "\"", "\"\""); + } + + /** + * Escapes identifier content for a position already surrounded by double + * quotes by doubling every embedded double quote. Preserves {@code null}. + */ + public static String escapeIdentifier(String identifier) { + return escapeIdentifierContent(identifier); + } + + private static boolean isValidQuotedIdentifier(String identifier) { + if (identifier.length() < 2 || identifier.charAt(0) != '"' + || identifier.charAt(identifier.length() - 1) != '"') { + return false; + } + for (int i = 1; i < identifier.length() - 1; i++) { + if (identifier.charAt(i) == '"') { + if (i + 1 >= identifier.length() - 1 || identifier.charAt(i + 1) != '"') { + return false; + } + i++; + } + } + return true; + } } diff --git a/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlite/src/test/java/ai/chat2db/plugin/sqlite/SqliteIdentifierProcessorTest.java b/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlite/src/test/java/ai/chat2db/plugin/sqlite/SqliteIdentifierProcessorTest.java new file mode 100644 index 0000000000..154c6e8c99 --- /dev/null +++ b/chat2db-community-server/chat2db-community-plugins/chat2db-community-sqlite/src/test/java/ai/chat2db/plugin/sqlite/SqliteIdentifierProcessorTest.java @@ -0,0 +1,302 @@ +package ai.chat2db.plugin.sqlite; + +import ai.chat2db.community.domain.api.config.TableBuilderConfig; +import ai.chat2db.community.domain.api.model.metadata.Table; +import ai.chat2db.community.domain.api.model.metadata.TableColumn; +import ai.chat2db.community.domain.api.model.metadata.TableIndex; +import ai.chat2db.community.domain.api.model.metadata.TableIndexColumn; +import ai.chat2db.plugin.sqlite.builder.SqliteBuilder; +import ai.chat2db.plugin.sqlite.constant.SqliteMetaDataConstants; +import ai.chat2db.plugin.sqlite.enums.type.SqliteColumnTypeEnum; +import ai.chat2db.plugin.sqlite.enums.type.SqliteIndexTypeEnum; +import ai.chat2db.plugin.sqlite.identifier.SqliteIdentifierProcessor; +import ai.chat2db.spi.model.request.DropTableRequest; +import ai.chat2db.spi.model.request.SingleInsertSqlRequest; +import ai.chat2db.spi.model.request.UpdateSqlRequest; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class SqliteIdentifierProcessorTest { + + @Test + void escapeStringDoublesSingleQuotes() { + assertEquals("O''Brien", SqliteIdentifierProcessor.INSTANCE.escapeString("O'Brien")); + assertEquals("''", SqliteIdentifierProcessor.INSTANCE.escapeString("'")); + assertEquals("plain", SqliteIdentifierProcessor.INSTANCE.escapeString("plain")); + assertNull(SqliteIdentifierProcessor.INSTANCE.escapeString(null)); + } + + @Test + void escapeIdentifierDoublesDoubleQuotesAndStripsWrappingQuotes() { + assertEquals("WE\"\"IRD", SqliteIdentifierProcessor.escapeIdentifier("WE\"IRD")); + assertEquals("\"\"ALREADY\"\"", SqliteIdentifierProcessor.escapeIdentifier("\"ALREADY\"")); + assertNull(SqliteIdentifierProcessor.escapeIdentifier(null)); + assertEquals("\"\"\"\"", SqliteIdentifierProcessor.escapeIdentifier("\"\"")); + assertEquals("\"A\"\"B\"", SqliteIdentifierProcessor.INSTANCE.quoteIdentifier("A\"B")); + assertEquals("\"\"\"\"", SqliteIdentifierProcessor.INSTANCE.quoteIdentifier("\"")); + } + + @Test + void alwaysQuoteRoundTripsEveryRawIdentifierShape() { + for (String raw : List.of("plain", "a\"b", "\"already\"", "\"", "a.b", "", " ")) { + assertEquals(raw, SqliteIdentifierProcessor.INSTANCE.removeIdentifierQuote( + SqliteIdentifierProcessor.INSTANCE.quoteIdentifierAlways(raw)), raw); + } + assertNull(SqliteIdentifierProcessor.INSTANCE.quoteIdentifierAlways(null)); + } + + @Test + void conditionalQuoteHandlesReservedAndAlreadyQuotedIdentifiers() { + assertEquals("plain_name", SqliteIdentifierProcessor.INSTANCE.quoteIdentifier("plain_name")); + assertEquals("\"SELECT\"", SqliteIdentifierProcessor.INSTANCE.quoteIdentifier("SELECT")); + assertEquals("\"a\"\"b\"", SqliteIdentifierProcessor.INSTANCE.quoteIdentifier("\"a\"\"b\"")); + } + + @Test + void metadataSqlTemplatesNeutralizeLiteralInjection() { + String payload = "v' OR '1'='1"; + String sql = String.format(SqliteMetaDataConstants.VIEW_DDL_SQL, SqliteIdentifierProcessor.INSTANCE.escapeString(payload)); + assertTrue(sql.contains("name='v'' OR ''1''=''1';"), sql); + assertFalse(sql.contains("name='v' OR"), sql); + } + + @Test + void getMetaDataNameNeutralizesEmbeddedQuotes() { + SqliteMetaData metaData = new SqliteMetaData(); + String result = metaData.getMetaDataName("main", "A\".\"B"); + assertEquals("\"main\".\"A\"\".\"\"B\"", result); + assertFalse(result.contains("A\".\"B\"."), "injection payload must not break out of the quoted identifier"); + } + + @Test + void identifierProcessorDoublesEmbeddedQuotes() { + SqliteIdentifierProcessor processor = new SqliteIdentifierProcessor(); + assertEquals("plain_name", processor.quoteIdentifier("plain_name")); + assertEquals("\"a\"\"b\"", processor.quoteIdentifier("a\"b")); + assertEquals("\"a\"\"; DROP TABLE t; --\"", processor.quoteIdentifier("a\"; DROP TABLE t; --")); + } + + @Test + void createTableEscapesNamesAndFlattensComments() { + Table table = Table.builder() + .databaseName("main") + .name("t\"; DROP TABLE u; --") + .columnList(List.of(TableColumn.builder() + .name("c\"d") + .columnType("TEXT") + .comment("x\n); DROP TABLE u; --") + .build())) + .indexList(List.of()) + .build(); + + String sql = new SqliteBuilder().buildCreateTable(table, TableBuilderConfig.defaultConfig()); + + assertTrue(sql.contains("\"t\"\"; DROP TABLE u; --\""), sql); + assertTrue(sql.contains("\"c\"\"d\""), sql); + assertFalse(sql.contains("x\n"), "comment must not break out of the -- line comment"); + } + + @Test + void createTableRejectsHostileFreeTextColumnType() { + Table table = Table.builder() + .name("t") + .columnList(List.of(TableColumn.builder() + .name("c") + .columnType("TEXT); DROP TABLE u; --") + .build())) + .indexList(List.of()) + .build(); + + assertThrows(IllegalArgumentException.class, + () -> new SqliteBuilder().buildCreateTable(table, TableBuilderConfig.defaultConfig())); + } + + @Test + void alterTableEscapesRenameTarget() { + Table oldTable = Table.builder().databaseName("main").name("users").columnList(List.of()).indexList(List.of()).build(); + Table newTable = Table.builder().databaseName("main").name("u\"; DROP TABLE t; --").columnList(List.of()).indexList(List.of()).build(); + + String sql = new SqliteBuilder().buildAlterTable(oldTable, newTable); + + assertTrue(sql.contains("RENAME TO \"u\"\"; DROP TABLE t; --\""), sql); + } + + @Test + void alterTableOmitsBlankDatabaseInsteadOfRenderingNullIdentifier() { + Table oldTable = Table.builder().name("users").columnList(List.of()).indexList(List.of()).build(); + Table newTable = Table.builder().name("people").columnList(List.of()).indexList(List.of()).build(); + + String sql = new SqliteBuilder().buildAlterTable(oldTable, newTable); + + assertTrue(sql.startsWith("ALTER TABLE \"users\""), sql); + assertFalse(sql.contains("\"null\""), sql); + } + + @Test + void indexScriptQuotesIndexTableAndColumnNames() { + TableIndex tableIndex = TableIndex.builder() + .name("i\"x") + .type("Normal") + .tableName("t\"y") + .columnList(List.of(TableIndexColumn.builder().columnName("c\"z").build())) + .build(); + + String sql = SqliteIndexTypeEnum.NORMAL.buildIndexScript(tableIndex); + + assertTrue(sql.contains("INDEX \"i\"\"x\" ON \"t\"\"y\" (\"c\"\"z\")"), sql); + } + + @Test + void createColumnSqlQuotesNameAndAcceptsBuiltinCollation() { + TableColumn column = TableColumn.builder() + .name("c\"d") + .columnType("TEXT") + .collationName("NOCASE") + .build(); + + String sql = SqliteColumnTypeEnum.TEXT.buildCreateColumnSql(column); + + assertTrue(sql.contains("\"c\"\"d\""), sql); + assertTrue(sql.contains("COLLATE NOCASE"), sql); + } + + @Test + void createColumnSqlRejectsHostileCollation() { + TableColumn column = TableColumn.builder() + .name("c") + .columnType("TEXT") + .collationName("NOCASE; DROP TABLE t; --") + .build(); + + assertThrows(IllegalArgumentException.class, () -> SqliteColumnTypeEnum.TEXT.buildCreateColumnSql(column)); + } + + @Test + void createColumnSqlPreservesLegalDefaultAndRejectsConstraintBreakout() { + TableColumn legal = TableColumn.builder() + .name("created_at") + .columnType("TEXT") + .defaultValue("strftime('%Y-%m-%d','now')") + .build(); + assertTrue(SqliteColumnTypeEnum.TEXT.buildCreateColumnSql(legal) + .contains("DEFAULT strftime('%Y-%m-%d','now')")); + + TableColumn hostile = TableColumn.builder() + .name("created_at") + .columnType("TEXT") + .defaultValue("CURRENT_TIMESTAMP UNIQUE") + .build(); + assertThrows(IllegalArgumentException.class, + () -> SqliteColumnTypeEnum.TEXT.buildCreateColumnSql(hostile)); + } + + @Test + void requireSafeTypeNameAcceptsRealTypesAndRejectsInjection() { + assertEquals("VARCHAR(255)", SqliteSqlGuards.requireSafeTypeName("VARCHAR(255)")); + assertEquals("NUMERIC(10,2)", SqliteSqlGuards.requireSafeTypeName("NUMERIC(10,2)")); + assertEquals("DOUBLE PRECISION", SqliteSqlGuards.requireSafeTypeName("DOUBLE PRECISION")); + assertThrows(IllegalArgumentException.class, + () -> SqliteSqlGuards.requireSafeTypeName("TEXT); DROP TABLE u; --")); + assertThrows(IllegalArgumentException.class, + () -> SqliteSqlGuards.requireSafeTypeName("TEXT\")")); + assertThrows(IllegalArgumentException.class, + () -> SqliteSqlGuards.requireSafeTypeName("TEXT NOT NULL")); + assertThrows(IllegalArgumentException.class, + () -> SqliteSqlGuards.requireSafeTypeName("NUMERIC(10,2")); + assertNull(SqliteSqlGuards.requireSafeTypeName(null)); + } + + @Test + void escapeColumnDefaultKeepsQuotedLiteralsAndExpressions() { + assertEquals("'O''Brien'", SqliteSqlGuards.escapeColumnDefault("'O''Brien'")); + assertEquals("''", SqliteSqlGuards.escapeColumnDefault("''")); + assertEquals("42", SqliteSqlGuards.escapeColumnDefault("42")); + assertEquals("-1.5", SqliteSqlGuards.escapeColumnDefault("-1.5")); + assertEquals("CURRENT_TIMESTAMP", SqliteSqlGuards.escapeColumnDefault("CURRENT_TIMESTAMP")); + assertEquals("(1+2)", SqliteSqlGuards.escapeColumnDefault("(1+2)")); + assertEquals("strftime('%Y-%m-%d','now')", + SqliteSqlGuards.escapeColumnDefault("strftime('%Y-%m-%d','now')")); + assertEquals("(json_extract('{\"a\":1}', '$.a'))", + SqliteSqlGuards.escapeColumnDefault("(json_extract('{\"a\":1}', '$.a'))")); + assertEquals("", SqliteSqlGuards.escapeColumnDefault(null)); + } + + @Test + void escapeColumnDefaultRejectsStatementAndConstraintBreakout() { + for (String payload : List.of( + "'x'); DROP TABLE u; --'", + "0; DROP TABLE u; --", + "0 NOT NULL", + "NULL REFERENCES victims", + "CURRENT_TIMESTAMP UNIQUE", + "f(1", + "1, other")) { + assertThrows(IllegalArgumentException.class, + () -> SqliteSqlGuards.escapeColumnDefault(payload), payload); + } + } + + @Test + void sanitizeLineCommentFlattensLineBreaks() { + assertEquals("x ); DROP TABLE u; --", SqliteSqlGuards.sanitizeLineComment("x\n); DROP TABLE u; --")); + assertEquals("a b", SqliteSqlGuards.sanitizeLineComment("a\r\nb")); + assertEquals("", SqliteSqlGuards.sanitizeLineComment(null)); + } + + @Test + void inheritedBuilderPathsAlwaysQuoteIdentifiers() { + SqliteBuilder builder = new SqliteBuilder(); + String database = "ma\"in"; + String table = "ta\"ble"; + String column = "co\"l"; + + assertTrue(builder.buildSelectTable(database, null, table) + .contains("\"ma\"\"in\".\"ta\"\"ble\"")); + assertTrue(builder.buildSelectCount(database, null, table) + .contains("\"ma\"\"in\".\"ta\"\"ble\"")); + assertTrue(builder.buildDropTable(new DropTableRequest(database, null, table)) + .endsWith("\"ma\"\"in\".\"ta\"\"ble\"")); + + String insert = builder.buildInsert(SingleInsertSqlRequest.builder() + .databaseName(database) + .tableName(table) + .columnList(List.of(column)) + .valueList(List.of("1")) + .build()); + assertTrue(insert.contains("\"ma\"\"in\".\"ta\"\"ble\" (\"co\"\"l\")"), insert); + + String update = builder.buildUpdate(UpdateSqlRequest.builder() + .databaseName(database) + .tableName(table) + .row(Map.of(column, "1")) + .primaryKeyMap(Map.of("id\"x", "2")) + .build()); + assertTrue(update.contains("UPDATE \"ma\"\"in\".\"ta\"\"ble\" SET \"co\"\"l\" = 1"), update); + assertTrue(update.contains("WHERE \"id\"\"x\" = 2"), update); + } + + @Test + void templatesAndDropIndexAlwaysQuoteIdentifiers() { + Table table = Table.builder() + .name("ta\"ble") + .columnList(List.of(TableColumn.builder().name("co\"l").build())) + .build(); + String template = new SqliteBuilder().buildTemplate(table, "SELECT"); + assertEquals("SELECT \"co\"\"l\" FROM \"ta\"\"ble\"", template); + + TableIndex index = TableIndex.builder() + .type("Normal") + .oldName("old\"index") + .editStatus("DELETE") + .build(); + assertEquals("DROP INDEX \"old\"\"index\"", SqliteIndexTypeEnum.NORMAL.buildModifyIndex(index)); + } +}