diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index 6b85c0372..efec7cc49 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -93,6 +93,7 @@ jobs: test_db_storage_policies \ test_api_handlers_storage_targets \ test_api_handlers_storage_policies \ + test_sqlite_migration_boundaries \ test_config \ test_logger \ test_strings \ @@ -146,7 +147,7 @@ jobs: run: | cd build ctest --output-on-failure -V \ - -R "test_storage_pressure$|test_storage_pressure_extended|test_detection_result_structures|test_stream_frame_rate|test_recording_video_params|test_mp4_segment_boundaries|test_storage_retention_sqlite|test_storage_manager_retention|test_storage_target_pressure_cleanup|test_storage_migration|test_storage_archive_s3|test_storage_archive_operator|test_authorization|test_api_handlers_recordings_playback|test_db_storage_targets|test_db_storage_policies|test_api_handlers_storage_targets|test_api_handlers_storage_policies|test_config|test_logger|test_strings|test_memory|test_request_response|test_shutdown_coordinator|test_detection_config|test_detection_model_motion|test_db_streams|test_db_camera_favorites|test_api_handlers_camera_favorites|test_db_backup|test_mp4_failed_open_memory|test_db_recordings_extended|test_db_detections|test_db_zones|test_db_events|test_db_auth|test_db_transactions|test_db_maintenance|test_db_query_builder|test_logger_json|test_batch_delete_progress|test_db_recordings_sync|test_httpd_utils|test_zone_filter|test_onvif_soap_fault|test_faac_encoder|test_audio_transcoder|test_mp4_audio_recovery|test_mp4_forced_reconnect|test_mp4_recovery_watchdog|test_audio_voice_enhancement|test_audio_voice_filter_faults|test_udt_recording|test_detection_current_frame|test_detection_io_recovery|test_hls_cleanup|test_stream_manager|test_stream_state|test_packet_buffer|test_timestamp_manager|test_motion_trigger_parse|test_motion_max_area|test_url_utils|test_system_health_startup$" + -R "test_sqlite_migration_boundaries|test_storage_pressure$|test_storage_pressure_extended|test_detection_result_structures|test_stream_frame_rate|test_recording_video_params|test_mp4_segment_boundaries|test_storage_retention_sqlite|test_storage_manager_retention|test_storage_target_pressure_cleanup|test_storage_migration|test_storage_archive_s3|test_storage_archive_operator|test_authorization|test_api_handlers_recordings_playback|test_db_storage_targets|test_db_storage_policies|test_api_handlers_storage_targets|test_api_handlers_storage_policies|test_config|test_logger|test_strings|test_memory|test_request_response|test_shutdown_coordinator|test_detection_config|test_detection_model_motion|test_db_streams|test_db_camera_favorites|test_api_handlers_camera_favorites|test_db_backup|test_mp4_failed_open_memory|test_db_recordings_extended|test_db_detections|test_db_zones|test_db_events|test_db_auth|test_db_transactions|test_db_maintenance|test_db_query_builder|test_logger_json|test_batch_delete_progress|test_db_recordings_sync|test_httpd_utils|test_zone_filter|test_onvif_soap_fault|test_faac_encoder|test_audio_transcoder|test_mp4_audio_recovery|test_mp4_forced_reconnect|test_mp4_recovery_watchdog|test_audio_voice_enhancement|test_audio_voice_filter_faults|test_udt_recording|test_detection_current_frame|test_detection_io_recovery|test_hls_cleanup|test_stream_manager|test_stream_state|test_packet_buffer|test_timestamp_manager|test_motion_trigger_parse|test_motion_max_area|test_url_utils|test_system_health_startup$" - name: Generate coverage report if: always() diff --git a/.github/workflows/sanitizer.yml b/.github/workflows/sanitizer.yml index 94e6ba7a1..5768c9a2c 100644 --- a/.github/workflows/sanitizer.yml +++ b/.github/workflows/sanitizer.yml @@ -60,7 +60,7 @@ jobs: cd build echo "Running tests with ASan + UBSan enabled..." ctest --output-on-failure -V \ - -R "test_config|test_logger|test_strings|test_memory|test_request_response|test_shutdown_coordinator|test_detection_config|test_detection_model_motion|test_db_streams|test_db_recordings_extended|test_db_detections|test_db_zones|test_db_events|test_db_auth|test_db_transactions|test_db_maintenance|test_db_query_builder|test_logger_json|test_batch_delete_progress|test_db_recordings_sync|test_httpd_utils|test_zone_filter|test_faac_encoder|test_audio_transcoder|test_audio_voice_enhancement|test_audio_voice_filter_faults|test_udt_recording|test_detection_current_frame|test_detection_io_recovery|test_stream_manager|test_stream_state|test_packet_buffer|test_timestamp_manager|test_storage_pressure$|test_storage_pressure_extended|test_detection_result_structures|test_stream_frame_rate|test_recording_video_params|test_mp4_segment_boundaries|test_storage_retention_sqlite|test_audit_log|test_audit_summary" + -R "test_sqlite_migration_boundaries|test_config|test_logger|test_strings|test_memory|test_request_response|test_shutdown_coordinator|test_detection_config|test_detection_model_motion|test_db_streams|test_db_recordings_extended|test_db_detections|test_db_zones|test_db_events|test_db_auth|test_db_transactions|test_db_maintenance|test_db_query_builder|test_logger_json|test_batch_delete_progress|test_db_recordings_sync|test_httpd_utils|test_zone_filter|test_faac_encoder|test_audio_transcoder|test_audio_voice_enhancement|test_audio_voice_filter_faults|test_udt_recording|test_detection_current_frame|test_detection_io_recovery|test_stream_manager|test_stream_state|test_packet_buffer|test_timestamp_manager|test_storage_pressure$|test_storage_pressure_extended|test_detection_result_structures|test_stream_frame_rate|test_recording_video_params|test_mp4_segment_boundaries|test_storage_retention_sqlite|test_audit_log|test_audit_summary" - name: Smoke test - start and stop service run: | diff --git a/src/database/sqlite_migrate.c b/src/database/sqlite_migrate.c index 4c07b29f6..7ea550d53 100644 --- a/src/database/sqlite_migrate.c +++ b/src/database/sqlite_migrate.c @@ -47,15 +47,16 @@ static int create_migrations_table(sqlite_migrate_t *ctx) { const char *table_name = ctx->config.migrations_table ? ctx->config.migrations_table : DEFAULT_MIGRATIONS_TABLE; - char sql[512]; - snprintf(sql, sizeof(sql), - "CREATE TABLE IF NOT EXISTS %s (" + char *sql = sqlite3_mprintf( + "CREATE TABLE IF NOT EXISTS \"%w\" (" " version TEXT PRIMARY KEY," " applied_at INTEGER NOT NULL DEFAULT (strftime('%%s', 'now'))" ");", table_name); + if (!sql) return -1; char *err_msg = NULL; int rc = sqlite3_exec(ctx->db, sql, NULL, NULL, &err_msg); + sqlite3_free(sql); if (rc != SQLITE_OK) { log_error("Failed to create migrations table: %s", err_msg); sqlite3_free(err_msg); @@ -72,12 +73,13 @@ static bool is_migration_applied(sqlite_migrate_t *ctx, const char *version) { const char *table_name = ctx->config.migrations_table ? ctx->config.migrations_table : DEFAULT_MIGRATIONS_TABLE; - char sql[256]; - snprintf(sql, sizeof(sql), - "SELECT 1 FROM %s WHERE version = ?;", table_name); + char *sql = sqlite3_mprintf( + "SELECT 1 FROM \"%w\" WHERE version = ?;", table_name); + if (!sql) return false; sqlite3_stmt *stmt; int rc = sqlite3_prepare_v2(ctx->db, sql, -1, &stmt, NULL); + sqlite3_free(sql); if (rc != SQLITE_OK) { return false; } @@ -97,12 +99,13 @@ static int record_migration_applied(sqlite_migrate_t *ctx, const char *version) const char *table_name = ctx->config.migrations_table ? ctx->config.migrations_table : DEFAULT_MIGRATIONS_TABLE; - char sql[256]; - snprintf(sql, sizeof(sql), - "INSERT INTO %s (version) VALUES (?);", table_name); + char *sql = sqlite3_mprintf( + "INSERT INTO \"%w\" (version) VALUES (?);", table_name); + if (!sql) return -1; sqlite3_stmt *stmt; int rc = sqlite3_prepare_v2(ctx->db, sql, -1, &stmt, NULL); + sqlite3_free(sql); if (rc != SQLITE_OK) { log_error("Failed to prepare migration record: %s", sqlite3_errmsg(ctx->db)); return -1; @@ -128,12 +131,13 @@ static int remove_migration_record(sqlite_migrate_t *ctx, const char *version) { const char *table_name = ctx->config.migrations_table ? ctx->config.migrations_table : DEFAULT_MIGRATIONS_TABLE; - char sql[256]; - snprintf(sql, sizeof(sql), - "DELETE FROM %s WHERE version = ?;", table_name); + char *sql = sqlite3_mprintf( + "DELETE FROM \"%w\" WHERE version = ?;", table_name); + if (!sql) return -1; sqlite3_stmt *stmt; int rc = sqlite3_prepare_v2(ctx->db, sql, -1, &stmt, NULL); + sqlite3_free(sql); if (rc != SQLITE_OK) { return -1; } @@ -234,11 +238,12 @@ static int validate_migration_file_security(const char *filepath) { * A CREATE TRIGGER body is a sequence of statements wrapped in BEGIN..END, so * splitting on the first semicolon would cut it in half: the allowlist would * reject the dangling END and sqlite3_exec would report "incomplete input". - * Treat the whole trigger, including nested CASE..END expressions, as one - * statement. Single-quoted literals are skipped everywhere. + * As in SQLite's complete.c, a trigger ends with the token sequence ; END ;. + * Counting BEGIN/CASE is incorrect because they can also be identifier names. + * Quoted literals/identifiers and comments are skipped everywhere. */ static bool is_word_char(char c) { - return isalnum((unsigned char)c) || c == '_'; + return isalnum((unsigned char)c) || c == '_' || c == '$' || (unsigned char)c >= 0x80; } /* Match `word` at `cursor` on identifier boundaries, so a column such as @@ -261,33 +266,49 @@ static const char *statement_end(const char *start) { trigger = word_matches(start, after, "TRIGGER", 7); } - int in_string = 0; - int depth = 0; /* BEGIN/CASE blocks still open */ - bool body_started = false; + char quote = 0; + unsigned terminator = 0; /* 0: other token, 1: semicolon, 2: semicolon END */ for (cursor = start; *cursor; cursor++) { - if (*cursor == '\'') { - in_string = !in_string; + if (quote) { + if (*cursor == quote) { + /* SQLite doubles quote delimiters inside strings/identifiers; + * bracket identifiers instead end at the first closing ]. */ + if (quote != ']' && cursor[1] == quote) cursor++; + else quote = 0; + } + continue; + } + if (*cursor == '\'' || *cursor == '"' || *cursor == '`' || *cursor == '[') { + terminator = 0; + quote = *cursor == '[' ? ']' : *cursor; + continue; + } + if (cursor[0] == '-' && cursor[1] == '-') { + while (*cursor && *cursor != '\n') cursor++; + if (!*cursor) return cursor; + continue; + } + if (cursor[0] == '/' && cursor[1] == '*') { + cursor += 2; + while (*cursor && !(cursor[0] == '*' && cursor[1] == '/')) cursor++; + if (!*cursor) return cursor; + cursor++; continue; } - if (in_string) continue; if (!trigger) { if (*cursor == ';') return cursor; continue; } - if (word_matches(start, cursor, "BEGIN", 5) || - word_matches(start, cursor, "CASE", 4)) { - depth++; - body_started = true; + if (isspace((unsigned char)*cursor)) continue; + if (*cursor == ';') { + if (terminator == 2) return cursor; + terminator = 1; continue; } - if (word_matches(start, cursor, "END", 3)) { - if (depth > 0) depth--; - if (body_started && depth == 0) { - cursor += 3; - while (*cursor && isspace((unsigned char)*cursor)) cursor++; - return cursor; - } - } + if (terminator == 1 && word_matches(start, cursor, "END", 3)) { + terminator = 2; + cursor += 2; + } else terminator = 0; } return cursor; } diff --git a/tests/unit/CMakeLists.txt b/tests/unit/CMakeLists.txt index 7bcee145f..684667ba7 100644 --- a/tests/unit/CMakeLists.txt +++ b/tests/unit/CMakeLists.txt @@ -139,6 +139,8 @@ target_sources(test_stream_frame_rate PRIVATE # ==================================================================== add_layer2_test(test_config) add_layer2_test(test_db_core_initialization) +add_layer2_test(test_sqlite_migration_boundaries) +set_tests_properties(test_sqlite_migration_boundaries PROPERTIES TIMEOUT 20) add_layer2_test(test_logger) add_layer2_test(test_strings) add_layer2_test(test_interruptible_sleep) diff --git a/tests/unit/test_sqlite_migration_boundaries.c b/tests/unit/test_sqlite_migration_boundaries.c new file mode 100644 index 000000000..bd4918a88 --- /dev/null +++ b/tests/unit/test_sqlite_migration_boundaries.c @@ -0,0 +1,245 @@ +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include "unity.h" +#include "database/sqlite_migrate.h" + +static char root[128], directory[192], path[256], outside[256]; +static sqlite3 *db; +static sqlite_migrate_t *migration; +static char *allocated_sql; +static const char ordinary_sql[] = + "-- migrate:up\nCREATE TABLE evidence(value TEXT); INSERT INTO evidence VALUES('kept');\n" + "-- migrate:down\nDROP TABLE evidence;\n"; + +static void write_fixture(const char *sql) { + FILE *file = fopen(path, "w"); TEST_ASSERT_NOT_NULL(file); + size_t count = fwrite(sql, 1, strlen(sql), file); int closed = fclose(file); + TEST_ASSERT_EQUAL_UINT(strlen(sql), count); TEST_ASSERT_EQUAL_INT(0, closed); + TEST_ASSERT_EQUAL_INT(0, chmod(path, 0600)); +} +static int scalar(const char *sql) { + sqlite3_stmt *statement = NULL; + int prepared = sqlite3_prepare_v2(db, sql, -1, &statement, NULL); + int stepped = prepared == SQLITE_OK ? sqlite3_step(statement) : prepared; + int value = stepped == SQLITE_ROW ? sqlite3_column_int(statement, 0) : -1; + if (statement) sqlite3_finalize(statement); + TEST_ASSERT_EQUAL_INT(SQLITE_OK, prepared); TEST_ASSERT_EQUAL_INT(SQLITE_ROW, stepped); + return value; +} +static bool table_exists(const char *name) { + sqlite3_stmt *statement = NULL; + int prepared = sqlite3_prepare_v2(db, "SELECT count(*) FROM sqlite_master WHERE type='table' AND name=?", -1, &statement, NULL); + int bound = prepared == SQLITE_OK ? sqlite3_bind_text(statement, 1, name, -1, SQLITE_TRANSIENT) : prepared; + int stepped = bound == SQLITE_OK ? sqlite3_step(statement) : bound; + int count = stepped == SQLITE_ROW ? sqlite3_column_int(statement, 0) : -1; + if (statement) sqlite3_finalize(statement); + TEST_ASSERT_EQUAL_INT(SQLITE_OK, prepared); TEST_ASSERT_EQUAL_INT(SQLITE_OK, bound); + TEST_ASSERT_EQUAL_INT(SQLITE_ROW, stepped); return count == 1; +} +static void assert_sentinel(void) { + TEST_ASSERT_TRUE(table_exists("sentinel")); + TEST_ASSERT_EQUAL_INT(73, scalar("SELECT value FROM sentinel")); +} +static void open_migrations(const char *tracking_table) { + migrate_config_t config = {.migrations_dir = directory, .migrations_table = tracking_table}; + migration = migrate_init(db, &config); TEST_ASSERT_NOT_NULL(migration); +} +void setUp(void) { + migration = NULL; db = NULL; allocated_sql = NULL; + strcpy(root, "/tmp/lightnvr-migration-boundary-XXXXXX"); TEST_ASSERT_NOT_NULL(mkdtemp(root)); + snprintf(directory, sizeof(directory), "%s/migrations", root); + snprintf(path, sizeof(path), "%s/0001_identifiers.sql", directory); + snprintf(outside, sizeof(outside), "%s/forbidden.sqlite", root); + TEST_ASSERT_EQUAL_INT(0, mkdir(directory, 0700)); + TEST_ASSERT_EQUAL_INT(SQLITE_OK, sqlite3_open(":memory:", &db)); + TEST_ASSERT_EQUAL_INT(SQLITE_OK, sqlite3_exec(db, "CREATE TABLE sentinel(value INTEGER); INSERT INTO sentinel VALUES(73);", NULL, NULL, NULL)); +} +void tearDown(void) { + migrate_free(migration); migration = NULL; + sqlite3_free(allocated_sql); allocated_sql = NULL; + if (db) sqlite3_close(db); + db = NULL; + unlink(path); unlink(outside); rmdir(directory); rmdir(root); +} +static void test_valid_tracking_name_applies_reloads_and_rolls_back(void) { + write_fixture(ordinary_sql); open_migrations("custom_tracking"); + TEST_ASSERT_EQUAL_INT(0, migrate_up(migration, NULL)); + TEST_ASSERT_EQUAL_INT(1, scalar("SELECT count(*) FROM custom_tracking")); + migrate_free(migration); migration = NULL; open_migrations("custom_tracking"); + char version[MIGRATE_VERSION_LEN]; TEST_ASSERT_EQUAL_INT(0, migrate_get_version(migration, version, sizeof(version))); + TEST_ASSERT_EQUAL_STRING("0001", version); + TEST_ASSERT_EQUAL_INT(1, migrate_down(migration)); + TEST_ASSERT_FALSE(table_exists("evidence")); + TEST_ASSERT_EQUAL_INT(0, scalar("SELECT count(*) FROM custom_tracking")); assert_sentinel(); +} +static void quoted_identifier_round_trip(const char *name) { + allocated_sql = sqlite3_mprintf("-- migrate:up\nCREATE TABLE \"%w\"(value TEXT);\n" + "-- migrate:down\nDROP TABLE \"%w\";\n", name, name); + TEST_ASSERT_NOT_NULL(allocated_sql); + /* SQLite itself accepts this quoted identifier; this is not malformed SQL. */ + char *control = sqlite3_mprintf("CREATE TABLE \"%w\"(value TEXT);", name); + TEST_ASSERT_NOT_NULL(control); + sqlite3_stmt *statement = NULL; + int prepared = sqlite3_prepare_v2(db, control, -1, &statement, NULL); + if (statement) sqlite3_finalize(statement); + sqlite3_free(control); + TEST_ASSERT_EQUAL_INT(SQLITE_OK, prepared); + write_fixture(allocated_sql); open_migrations(NULL); + int applied = migrate_up(migration, NULL); assert_sentinel(); + TEST_ASSERT_EQUAL_INT(0, applied); TEST_ASSERT_TRUE(table_exists(name)); + TEST_ASSERT_EQUAL_INT(1, migrate_down(migration)); + TEST_ASSERT_FALSE(table_exists(name)); assert_sentinel(); +} +static void test_double_quote_in_identifier_round_trips(void) { quoted_identifier_round_trip("quoted\"identifier"); } +static void test_apostrophe_in_quoted_identifier_round_trips(void) { quoted_identifier_round_trip("quoted'identifier"); } +static void test_semicolon_and_comment_text_in_quoted_identifier_round_trip(void) { + quoted_identifier_round_trip("semi;--/*identifier*/"); +} +static void statement_round_trip(const char *up, const char *down) { + allocated_sql = sqlite3_mprintf("-- migrate:up\n%s\n-- migrate:down\n%s\n", up, down); + TEST_ASSERT_NOT_NULL(allocated_sql); + write_fixture(allocated_sql); open_migrations(NULL); + TEST_ASSERT_EQUAL_INT(0, migrate_up(migration, NULL)); assert_sentinel(); + TEST_ASSERT_EQUAL_INT(1, migrate_down(migration)); assert_sentinel(); +} +static void test_backtick_identifier_and_escaped_delimiter_round_trip(void) { + statement_round_trip("CREATE TABLE `semi;quo``te'name`(value TEXT);", + "DROP TABLE `semi;quo``te'name`;"); +} +static void test_bracket_identifier_round_trip(void) { + statement_round_trip("CREATE TABLE [semi;quote'name](value TEXT);", + "DROP TABLE [semi;quote'name];"); +} +static void test_inline_comments_do_not_change_statement_boundaries(void) { + statement_round_trip("CREATE TABLE evidence /* '; END */ (value TEXT);\n" + "INSERT INTO evidence VALUES('it''s; kept'); -- '; BEGIN\n" + "UPDATE evidence SET value='changed';", + "DROP TABLE evidence /* '; BEGIN */;"); +} +static void test_trigger_quoted_keywords_and_nested_case_round_trip(void) { + statement_round_trip("CREATE TABLE evidence(value TEXT);" + "CREATE TRIGGER \"BEGIN;END\" AFTER INSERT ON evidence BEGIN " + "UPDATE evidence SET value=CASE WHEN new.value='it''s;' THEN 'END' ELSE 'BEGIN' END; END;" + "INSERT INTO evidence VALUES('it''s;');", + "DROP TRIGGER \"BEGIN;END\"; DROP TABLE evidence;"); +} +static void trigger_following_statement_is_validated(const char *name) { + allocated_sql = sqlite3_mprintf("-- migrate:up\nCREATE TABLE evidence(value TEXT);" + "CREATE TRIGGER %s AFTER INSERT ON evidence BEGIN SELECT 1; END;" + "ATTACH DATABASE '%q' AS escaped;\n-- migrate:down\nDROP TABLE evidence;\n", name, outside); + TEST_ASSERT_NOT_NULL(allocated_sql); write_fixture(allocated_sql); open_migrations(NULL); + int result = migrate_up(migration, NULL); + assert_sentinel(); TEST_ASSERT_EQUAL_INT(-1, access(outside, F_OK)); + TEST_ASSERT_EQUAL_INT(-1, result); TEST_ASSERT_FALSE(table_exists("evidence")); + TEST_ASSERT_EQUAL_INT(0, scalar("SELECT count(*) FROM schema_migrations")); +} +static void test_trigger_named_begin_does_not_hide_following_statement(void) { + trigger_following_statement_is_validated("BEGIN"); +} +static void test_trigger_dollar_identifier_does_not_hide_following_statement(void) { + trigger_following_statement_is_validated("BEGIN$name"); +} +static void test_trigger_unicode_identifier_does_not_hide_following_statement(void) { + trigger_following_statement_is_validated("BEGIN\xc3\xa9"); +} +static void test_trigger_keyword_name_and_terminator_comments_round_trip(void) { + statement_round_trip("CREATE TABLE evidence(value TEXT);" + "CREATE TRIGGER BEGIN AFTER INSERT ON evidence BEGIN " + "SELECT CASE WHEN 1 THEN 'END;' ELSE ';END;' END; /* ; END */ END /* ; */;" + "INSERT INTO evidence VALUES('kept');", + "DROP TRIGGER BEGIN; DROP TABLE evidence;"); +} +static void test_up_identifier_quote_cannot_hide_disallowed_attach(void) { + char sql[1024]; + int length = snprintf(sql, sizeof(sql), "-- migrate:up\n" + "CREATE TABLE \"quoted'name\"(value TEXT); ATTACH DATABASE '%s' AS escaped;\n" + "-- migrate:down\nDROP TABLE \"quoted'name\";\n", outside); + TEST_ASSERT_TRUE(length > 0 && (size_t)length < sizeof(sql)); + write_fixture(sql); open_migrations(NULL); + int applied = migrate_up(migration, NULL); + assert_sentinel(); TEST_ASSERT_EQUAL_INT(-1, access(outside, F_OK)); + TEST_ASSERT_EQUAL_INT(1, scalar("SELECT count(*) FROM pragma_database_list")); + TEST_ASSERT_EQUAL_INT(-1, applied); TEST_ASSERT_FALSE(table_exists("quoted'name")); + TEST_ASSERT_EQUAL_INT(0, scalar("SELECT count(*) FROM schema_migrations")); +} +static void test_down_identifier_quote_cannot_hide_disallowed_attach(void) { + char sql[1024]; + int length = snprintf(sql, sizeof(sql), "-- migrate:up\nCREATE TABLE \"quoted'name\"(value TEXT);\n" + "-- migrate:down\nDROP TABLE \"quoted'name\"; ATTACH DATABASE '%s' AS escaped;\n", outside); + TEST_ASSERT_TRUE(length > 0 && (size_t)length < sizeof(sql)); + write_fixture(sql); open_migrations(NULL); + TEST_ASSERT_EQUAL_INT(0, migrate_up(migration, NULL)); + int rolled_back = migrate_down(migration); + assert_sentinel(); TEST_ASSERT_EQUAL_INT(-1, access(outside, F_OK)); + TEST_ASSERT_EQUAL_INT(1, scalar("SELECT count(*) FROM pragma_database_list")); + TEST_ASSERT_EQUAL_INT(-1, rolled_back); TEST_ASSERT_TRUE(table_exists("quoted'name")); + TEST_ASSERT_EQUAL_INT(1, scalar("SELECT count(*) FROM schema_migrations")); +} +static void test_tracking_identifier_cannot_execute_second_statement(void) { + migrate_config_t config = {.migrations_table = "injected(version TEXT); DROP TABLE sentinel; --"}; + migration = migrate_init(db, &config); + /* Either literal treatment or rejection is safe; execution of fragments is not. */ + assert_sentinel(); TEST_ASSERT_FALSE(table_exists("injected")); +} +static void test_tracking_quote_and_comment_names_cannot_change_unrelated_schema(void) { + const char *names[] = {"quote\"tracking", "tracking;--comment", "tracking/*comment*/"}; + for (size_t index = 0; index < sizeof(names)/sizeof(names[0]); ++index) { + migrate_config_t config = {.migrations_table = names[index]}; + migration = migrate_init(db, &config); assert_sentinel(); + migrate_free(migration); migration = NULL; + } +} +static void tracking_round_trip(const char *name) { + write_fixture(ordinary_sql); open_migrations(name); + TEST_ASSERT_TRUE(table_exists(name)); + TEST_ASSERT_EQUAL_INT(0, migrate_up(migration, NULL)); assert_sentinel(); + migrate_free(migration); migration = NULL; open_migrations(name); + char version[MIGRATE_VERSION_LEN]; + TEST_ASSERT_EQUAL_INT(0, migrate_get_version(migration, version, sizeof(version))); + TEST_ASSERT_EQUAL_STRING("0001", version); + TEST_ASSERT_EQUAL_INT(1, migrate_down(migration)); assert_sentinel(); + TEST_ASSERT_FALSE(table_exists("evidence")); +} +static void test_quoted_tracking_name_round_trips_as_one_identifier(void) { + tracking_round_trip("track\"ing; -- /*name*/ 'quoted'"); +} +static void test_long_tracking_name_round_trips_without_truncation(void) { + char name[1025]; memset(name, 'x', sizeof(name) - 1); name[sizeof(name) - 1] = '\0'; + tracking_round_trip(name); +} +static void test_migration_version_is_bound_as_data(void) { + migration_t embedded = {.version = "v'; DROP TABLE sentinel;--", .description = "bound value", + .sql_up = "CREATE TABLE evidence(value TEXT);", .sql_down = "DROP TABLE evidence;"}; + migrate_config_t config = {.embedded_migrations = &embedded, .embedded_count = 1}; + migration = migrate_init(db, &config); TEST_ASSERT_NOT_NULL(migration); + TEST_ASSERT_EQUAL_INT(0, migrate_up(migration, NULL)); assert_sentinel(); + TEST_ASSERT_EQUAL_INT(1, migrate_down(migration)); assert_sentinel(); +} +int main(void) { + UNITY_BEGIN(); + RUN_TEST(test_valid_tracking_name_applies_reloads_and_rolls_back); + RUN_TEST(test_double_quote_in_identifier_round_trips); + RUN_TEST(test_apostrophe_in_quoted_identifier_round_trips); + RUN_TEST(test_semicolon_and_comment_text_in_quoted_identifier_round_trip); + RUN_TEST(test_backtick_identifier_and_escaped_delimiter_round_trip); + RUN_TEST(test_bracket_identifier_round_trip); + RUN_TEST(test_inline_comments_do_not_change_statement_boundaries); + RUN_TEST(test_trigger_quoted_keywords_and_nested_case_round_trip); + RUN_TEST(test_trigger_named_begin_does_not_hide_following_statement); + RUN_TEST(test_trigger_dollar_identifier_does_not_hide_following_statement); + RUN_TEST(test_trigger_unicode_identifier_does_not_hide_following_statement); + RUN_TEST(test_trigger_keyword_name_and_terminator_comments_round_trip); + RUN_TEST(test_up_identifier_quote_cannot_hide_disallowed_attach); + RUN_TEST(test_down_identifier_quote_cannot_hide_disallowed_attach); + RUN_TEST(test_tracking_identifier_cannot_execute_second_statement); + RUN_TEST(test_tracking_quote_and_comment_names_cannot_change_unrelated_schema); + RUN_TEST(test_quoted_tracking_name_round_trips_as_one_identifier); + RUN_TEST(test_long_tracking_name_round_trips_without_truncation); + RUN_TEST(test_migration_version_is_bound_as_data); + return UNITY_END(); +}