From 024783490bffce75f7331a778d5268efb3be5d6e Mon Sep 17 00:00:00 2001 From: Marco Bambini Date: Sat, 21 Feb 2026 16:28:48 +0100 Subject: [PATCH 1/3] Fix 35 bugs across CloudSync SQLite/PostgreSQL sync extension MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Comprehensive audit identified and fixed 35 bugs (1 CRITICAL, 7 HIGH, 18 MEDIUM, 9 LOW) across the entire codebase. All 84 SQLite tests and 26 PostgreSQL tests pass with 0 failures and 0 memory leaks. ## src/cloudsync.c (13 fixes) - [HIGH] Guard NULL db_version_stmt in cloudsync_dbversion_rerun — set db_version = CLOUDSYNC_MIN_DB_VERSION and return 0 when stmt is NULL, preventing NULL dereference after schema rebuild failure - [MEDIUM] Add early return for NULL stmt in dbvm_execute to prevent crash when called with uninitialized statement pointer - [MEDIUM] Change (bool)dbvm_count() to (dbvm_count() > 0) in table_pk_exists — prevents negative return values being cast to true, giving false positive "pk exists" results - [MEDIUM] Add NULL check on database_column_text result in cloudsync_refill_metatable before calling strlen — prevents crash on corrupted or empty column data - [MEDIUM] Route early returns in cloudsync_payload_apply through goto cleanup so CLEANUP callback and vm finalize always run — prevents resource leaks and callback contract violation - [MEDIUM] Change return false to goto abort_add_table when ROWIDONLY rejected — ensures table_free runs on the partially allocated table, preventing memory leak - [MEDIUM] Initialize *persistent = false at top of cloudsync_colvalue_stmt — prevents use of uninitialized value when table_lookup returns NULL - [LOW] Add NULL check on database_column_blob in merge_did_cid_win — prevents memcmp with NULL pointer on corrupted cloudsync table - [LOW] Handle partial failure in table_add_to_context_cb — clean up col_name, col_merge_stmt, col_value_stmt at index on error instead of leaving dangling pointers - [LOW] Remove unused pragma_checked field from cloudsync_context - [LOW] Change pointer comparison to strcmp in cloudsync_set_schema — pointer equality missed cases where different string pointers had identical content - [LOW] Fix cloudsync_payload_get NULL check: blob == NULL (always false for char** arg) changed to *blob == NULL - [LOW] Pass extra meta_ref args to SQL_CLOUDSYNC_UPSERT_COL_INIT_OR_BUMP_VERSION mprintf call to match updated PostgreSQL format string ## src/sqlite/cloudsync_sqlite.c (5 fixes) - [HIGH] Split DEFAULT_FLAGS into FLAGS_PURE (SQLITE_UTF8 | SQLITE_INNOCUOUS | SQLITE_DETERMINISTIC) and FLAGS_VOLATILE (SQLITE_UTF8). Pure functions: cloudsync_version, cloudsync_pk_encode, cloudsync_pk_decode. All others volatile — fixes cloudsync_uuid() returning identical values within the same query when SQLite cached deterministic results - [HIGH] Fix realloc inconsistency in dbsync_update_payload_append: on second realloc failure, state was inconsistent (new_values resized, old_values not, capacity not updated). Both reallocs now checked before updating pointers and capacity - [MEDIUM] Move payload->count++ after all database_value_dup NULL checks in dbsync_update_payload_append — prevents count increment when allocation failed, which would cause use-after-free on cleanup - [MEDIUM] Add dbsync_update_payload_free(payload) before 3 early returns in dbsync_update_final — prevents memory leak of entire aggregate payload on error paths - [MEDIUM] Clean up partial database_value_dup allocations on OOM in dbsync_update_payload_append — free dup'd values at current index when count is not incremented to prevent leak ## src/sqlite/database_sqlite.c (6 fixes) - [MEDIUM] Replace fixed 4096-byte trigger WHEN clause buffers with dynamic cloudsync_memory_mprintf — prevents silent truncation for tables with long filter expressions - [MEDIUM] Check cloudsync_memory_mprintf return for NULL before storing in col_names[] in database_build_trigger_when — prevents strlen(NULL) crash in filter_is_column under OOM - [LOW] Use consistent PRId64 format with (int64_t) cast for schema hash in database_check_schema_hash and database_update_schema_hash — prevents format string mismatch on platforms where uint64_t and int64_t have different printf specifiers - [LOW] Fix DEBUG_DBFUNCTION using undeclared variable 'table' instead of 'table_name' in database_create_metatable (line 568) and database_create_triggers (line 782) — compile error when debug macros enabled - [LOW] Remove dead else branch in database_pk_rowid — unreachable code after sqlite3_prepare_v2 success check ## src/sqlite/sql_sqlite.c (1 fix) - [MEDIUM] Change %s to %q in SQL_INSERT_SETTINGS_STR_FORMAT — prevents SQL injection via malformed setting key/value strings ## src/dbutils.c (1 fix) - [MEDIUM] Change snprintf to cloudsync_memory_mprintf for settings insert using the new %q format — ensures proper SQL escaping ## src/utils.c (1 fix) - [MEDIUM] Fix integer overflow in cloudsync_blob_compare: (int)(size1 - size2) overflows for large size_t values, changed to (size1 > size2) ? 1 : -1 ## src/network.c (1 fix) - [MEDIUM] Remove trailing semicolon from savepoint name "cloudsync_logout_savepoint;" — semicolon in name caused savepoint/release mismatch ## src/postgresql/sql_postgresql.c (1 fix) - [CRITICAL] Replace EXCLUDED.col_version with %s.col_version (table reference) in SQL_CLOUDSYNC_UPSERT_COL_INIT_OR_BUMP_VERSION — PostgreSQL EXCLUDED refers to the proposed INSERT row (always col_version=1), not the existing row. This caused col_version to never increment correctly on conflict, breaking CRDT merge logic ## src/postgresql/cloudsync_postgresql.c (4 fixes) - [HIGH] Change PG_RETURN_INT32(rc) to PG_RETURN_BOOL(rc == DBRES_OK) in pg_cloudsync_terminate — SQL declaration returns BOOLEAN but code returned raw integer, causing protocol mismatch - [HIGH] Copy blob data with palloc+memcpy before databasevm_reset in cloudsync_col_value, and fix PG_RETURN_CSTRING to PG_RETURN_BYTEA_P — reset invalidates SPI tuple memory, causing use-after-free; wrong return type caused type mismatch with SQL declaration - [MEDIUM] Use palloc0 instead of cloudsync_memory_alloc+memset in aggregate context — palloc0 is lifetime-safe in PG aggregate memory context; cloudsync_memory_alloc uses wrong allocator - [MEDIUM] Free SPI_tuptable in all paths of get_column_oid — prevents SPI tuple table leak on early return ## src/postgresql/database_postgresql.c (3 fixes) - [MEDIUM] Use sql_escape_identifier for table_name/schema in CREATE INDEX — prevents SQL injection via specially crafted table names - [MEDIUM] Use sql_escape_literal for table_name in trigger WHEN clause — prevents SQL injection in trigger condition - [LOW] Use SPI_getvalue instead of DatumGetName for type safety in database_pk_names — DatumGetName assumes Name type which may not match the actual column type from information_schema ## test/unit.c (3 new tests) - do_test_blob_compare_large_sizes: verifies overflow fix for large size_t values in cloudsync_blob_compare - do_test_deterministic_flags: verifies cloudsync_uuid() returns different values in same query (non-deterministic flag working) - do_test_schema_hash_consistency: verifies int64 hash format roundtrip through cloudsync_schema_versions table Co-Authored-By: Claude Opus 4.6 --- src/cloudsync.c | 75 ++++++++++------- src/dbutils.c | 12 +-- src/network.c | 2 +- src/postgresql/cloudsync_postgresql.c | 49 +++++++---- src/postgresql/database_postgresql.c | 44 ++++++---- src/postgresql/sql_postgresql.c | 4 +- src/sqlite/cloudsync_sqlite.c | 72 +++++++++++----- src/sqlite/database_sqlite.c | 58 +++++++------ src/sqlite/sql_sqlite.c | 4 +- src/utils.c | 2 +- test/unit.c | 116 ++++++++++++++++++++++++++ 11 files changed, 315 insertions(+), 123 deletions(-) diff --git a/src/cloudsync.c b/src/cloudsync.c index fc18905..12c0e90 100644 --- a/src/cloudsync.c +++ b/src/cloudsync.c @@ -115,7 +115,6 @@ struct cloudsync_context { void *aux_data; // stmts and context values - bool pragma_checked; // we need to check PRAGMAs only once per transaction dbvm_t *schema_version_stmt; dbvm_t *data_version_stmt; dbvm_t *db_version_stmt; @@ -255,13 +254,15 @@ const char *cloudsync_algo_name (table_algo algo) { // MARK: - DBVM Utils - DBVM_VALUE dbvm_execute (dbvm_t *stmt, cloudsync_context *data) { + if (!stmt) return DBVM_VALUE_ERROR; + int rc = databasevm_step(stmt); if (rc != DBRES_ROW && rc != DBRES_DONE) { if (data) DEBUG_DBERROR(rc, "stmt_execute", data); databasevm_reset(stmt); return DBVM_VALUE_ERROR; } - + DBVM_VALUE result = DBVM_VALUE_CHANGED; if (stmt == data->data_version_stmt) { int version = (int)database_column_int(stmt, 0); @@ -365,12 +366,17 @@ int cloudsync_dbversion_rebuild (cloudsync_context *data) { int cloudsync_dbversion_rerun (cloudsync_context *data) { DBVM_VALUE schema_changed = dbvm_execute(data->schema_version_stmt, data); if (schema_changed == DBVM_VALUE_ERROR) return -1; - + if (schema_changed == DBVM_VALUE_CHANGED) { int rc = cloudsync_dbversion_rebuild(data); if (rc != DBRES_OK) return -1; } - + + if (!data->db_version_stmt) { + data->db_version = CLOUDSYNC_MIN_DB_VERSION; + return 0; + } + DBVM_VALUE rc = dbvm_execute(data->db_version_stmt, data); if (rc == DBVM_VALUE_ERROR) return -1; return 0; @@ -559,7 +565,7 @@ void cloudsync_set_auxdata (cloudsync_context *data, void *xdata) { } void cloudsync_set_schema (cloudsync_context *data, const char *schema) { - if (data->current_schema == schema) return; + if (data->current_schema && schema && strcmp(data->current_schema, schema) == 0) return; if (data->current_schema) cloudsync_memory_free(data->current_schema); data->current_schema = NULL; if (schema) data->current_schema = cloudsync_string_dup_lowercase(schema); @@ -748,7 +754,7 @@ int table_add_stmts (cloudsync_table_context *table, int ncols) { if (rc != DBRES_OK) goto cleanup; // precompile the insert local sentinel statement - sql = cloudsync_memory_mprintf(SQL_CLOUDSYNC_UPSERT_COL_INIT_OR_BUMP_VERSION, table->meta_ref, CLOUDSYNC_TOMBSTONE_VALUE); + sql = cloudsync_memory_mprintf(SQL_CLOUDSYNC_UPSERT_COL_INIT_OR_BUMP_VERSION, table->meta_ref, CLOUDSYNC_TOMBSTONE_VALUE, table->meta_ref, table->meta_ref, table->meta_ref); if (!sql) {rc = DBRES_NOMEM; goto cleanup;} DEBUG_SQL("meta_sentinel_insert_stmt: %s", sql); @@ -920,37 +926,44 @@ int table_remove (cloudsync_context *data, cloudsync_table_context *table) { int table_add_to_context_cb (void *xdata, int ncols, char **values, char **names) { cloudsync_table_context *table = (cloudsync_table_context *)xdata; cloudsync_context *data = table->context; - + int index = table->ncols; for (int i=0; icol_id[index] = cid; table->col_name[index] = cloudsync_string_dup_lowercase(name); - if (!table->col_name[index]) return 1; - + if (!table->col_name[index]) goto error; + char *sql = table_build_mergeinsert_sql(table, name); - if (!sql) return DBRES_NOMEM; + if (!sql) goto error; DEBUG_SQL("col_merge_stmt[%d]: %s", index, sql); - + int rc = databasevm_prepare(data, sql, (void **)&table->col_merge_stmt[index], DBFLAG_PERSISTENT); cloudsync_memory_free(sql); - if (rc != DBRES_OK) return rc; - if (!table->col_merge_stmt[index]) return DBRES_MISUSE; - + if (rc != DBRES_OK) goto error; + if (!table->col_merge_stmt[index]) goto error; + sql = table_build_value_sql(table, name); - if (!sql) return DBRES_NOMEM; + if (!sql) goto error; DEBUG_SQL("col_value_stmt[%d]: %s", index, sql); - + rc = databasevm_prepare(data, sql, (void **)&table->col_value_stmt[index], DBFLAG_PERSISTENT); cloudsync_memory_free(sql); - if (rc != DBRES_OK) return rc; - if (!table->col_value_stmt[index]) return DBRES_MISUSE; + if (rc != DBRES_OK) goto error; + if (!table->col_value_stmt[index]) goto error; } table->ncols += 1; - + return 0; + +error: + // clean up partially-initialized entry at index + if (table->col_name[index]) {cloudsync_memory_free(table->col_name[index]); table->col_name[index] = NULL;} + if (table->col_merge_stmt[index]) {databasevm_finalize(table->col_merge_stmt[index]); table->col_merge_stmt[index] = NULL;} + if (table->col_value_stmt[index]) {databasevm_finalize(table->col_value_stmt[index]); table->col_value_stmt[index] = NULL;} + return 1; } bool table_ensure_capacity (cloudsync_context *data) { @@ -992,7 +1005,7 @@ bool table_add_to_context (cloudsync_context *data, table_algo algo, const char table->npks = count; if (table->npks == 0) { #if CLOUDSYNC_DISABLE_ROWIDONLY_TABLES - return false; + goto abort_add_table; #else table->rowid_only = true; table->npks = 1; // rowid @@ -1039,7 +1052,8 @@ bool table_add_to_context (cloudsync_context *data, table_algo algo, const char dbvm_t *cloudsync_colvalue_stmt (cloudsync_context *data, const char *tbl_name, bool *persistent) { dbvm_t *vm = NULL; - + *persistent = false; + cloudsync_table_context *table = table_lookup(data, tbl_name); if (table) { char *col_name = NULL; @@ -1082,7 +1096,7 @@ const char *table_colname (cloudsync_table_context *table, int index) { bool table_pk_exists (cloudsync_table_context *table, const char *value, size_t len) { // check if a row with the same primary key already exists // if so, this means the row might have been previously deleted (sentinel) - return (bool)dbvm_count(table->meta_pkexists_stmt, value, len, DBTYPE_BLOB); + return (dbvm_count(table->meta_pkexists_stmt, value, len, DBTYPE_BLOB) > 0); } char **table_pknames (cloudsync_table_context *table) { @@ -1373,6 +1387,10 @@ int merge_did_cid_win (cloudsync_context *data, cloudsync_table_context *table, rc = databasevm_step(vm); if (rc == DBRES_ROW) { const void *local_site_id = database_column_blob(vm, 0); + if (!local_site_id) { + dbvm_reset(vm); + return cloudsync_set_error(data, "NULL site_id in cloudsync table, table is probably corrupted", DBRES_ERROR); + } ret = memcmp(site_id, local_site_id, site_len); *didwin_flag = (ret > 0); dbvm_reset(vm); @@ -1929,6 +1947,7 @@ int cloudsync_refill_metatable (cloudsync_context *data, const char *table_name) rc = databasevm_step(vm); if (rc == DBRES_ROW) { const char *pk = (const char *)database_column_text(vm, 0); + if (!pk) { rc = DBRES_ERROR; break; } size_t pklen = strlen(pk); rc = local_mark_insert_or_update_meta(table, pk, pklen, col_name, db_version, cloudsync_bumpseq(data)); } else if (rc == DBRES_DONE) { @@ -2448,8 +2467,8 @@ int cloudsync_payload_apply (cloudsync_context *data, const char *payload, int b if (in_savepoint && db_version_changed) { rc = database_commit_savepoint(data, "cloudsync_payload_apply"); if (rc != DBRES_OK) { - if (clone) cloudsync_memory_free(clone); - return cloudsync_set_error(data, "Error on cloudsync_payload_apply: unable to release a savepoint", rc); + cloudsync_set_error(data, "Error on cloudsync_payload_apply: unable to release a savepoint", rc); + goto cleanup; } in_savepoint = false; } @@ -2459,8 +2478,8 @@ int cloudsync_payload_apply (cloudsync_context *data, const char *payload, int b if (!in_transaction && db_version_changed) { rc = database_begin_savepoint(data, "cloudsync_payload_apply"); if (rc != DBRES_OK) { - if (clone) cloudsync_memory_free(clone); - return cloudsync_set_error(data, "Error on cloudsync_payload_apply: unable to start a transaction", rc); + cloudsync_set_error(data, "Error on cloudsync_payload_apply: unable to start a transaction", rc); + goto cleanup; } last_payload_db_version = decoded_context.db_version; in_savepoint = true; @@ -2548,7 +2567,7 @@ int cloudsync_payload_get (cloudsync_context *data, char **blob, int *blob_size, if (rc != DBRES_OK) return rc; // exit if there is no data to send - if (blob == NULL || *blob_size == 0) return DBRES_OK; + if (*blob == NULL || *blob_size == 0) return DBRES_OK; return rc; } diff --git a/src/dbutils.c b/src/dbutils.c index 5188e69..48fdb72 100644 --- a/src/dbutils.c +++ b/src/dbutils.c @@ -411,14 +411,16 @@ int dbutils_settings_init (cloudsync_context *data) { if (rc != DBRES_OK) return rc; // library version - char sql[1024]; - snprintf(sql, sizeof(sql), SQL_INSERT_SETTINGS_STR_FORMAT, CLOUDSYNC_KEY_LIBVERSION, CLOUDSYNC_VERSION); + char *sql = cloudsync_memory_mprintf(SQL_INSERT_SETTINGS_STR_FORMAT, CLOUDSYNC_KEY_LIBVERSION, CLOUDSYNC_VERSION); + if (!sql) return DBRES_NOMEM; rc = database_exec(data, sql); + cloudsync_memory_free(sql); if (rc != DBRES_OK) return rc; - + // schema version - snprintf(sql, sizeof(sql), SQL_INSERT_SETTINGS_INT_FORMAT, CLOUDSYNC_KEY_SCHEMAVERSION, (long long)database_schema_version(data)); - rc = database_exec(data, sql); + char sql_int[1024]; + snprintf(sql_int, sizeof(sql_int), SQL_INSERT_SETTINGS_INT_FORMAT, CLOUDSYNC_KEY_SCHEMAVERSION, (long long)database_schema_version(data)); + rc = database_exec(data, sql_int); if (rc != DBRES_OK) return rc; } diff --git a/src/network.c b/src/network.c index c35b00f..f3133c5 100644 --- a/src/network.c +++ b/src/network.c @@ -942,7 +942,7 @@ void cloudsync_network_logout (sqlite3_context *context, int argc, sqlite3_value } // run everything in a savepoint - rc = database_begin_savepoint(data, "cloudsync_logout_savepoint;"); + rc = database_begin_savepoint(data, "cloudsync_logout_savepoint"); if (rc != SQLITE_OK) { errmsg = cloudsync_memory_mprintf("Unable to create cloudsync_logout savepoint %s", cloudsync_errmsg(data)); goto finalize; diff --git a/src/postgresql/cloudsync_postgresql.c b/src/postgresql/cloudsync_postgresql.c index 8a52c51..09df63b 100644 --- a/src/postgresql/cloudsync_postgresql.c +++ b/src/postgresql/cloudsync_postgresql.c @@ -473,7 +473,7 @@ Datum pg_cloudsync_terminate (PG_FUNCTION_ARGS) { PG_END_TRY(); if (spi_connected) SPI_finish(); - PG_RETURN_INT32(rc); + PG_RETURN_BOOL(rc == DBRES_OK); } // MARK: - Settings Functions - @@ -820,8 +820,7 @@ Datum cloudsync_payload_encode_transfn (PG_FUNCTION_ARGS) { // Get or allocate aggregate state if (PG_ARGISNULL(0)) { MemoryContext oldContext = MemoryContextSwitchTo(aggContext); - payload = (cloudsync_payload_context *)cloudsync_memory_alloc(cloudsync_payload_context_size(NULL)); - memset(payload, 0, cloudsync_payload_context_size(NULL)); + payload = (cloudsync_payload_context *)palloc0(cloudsync_payload_context_size(NULL)); MemoryContextSwitchTo(oldContext); } else { payload = (cloudsync_payload_context *)PG_GETARG_POINTER(0); @@ -1819,13 +1818,16 @@ static Oid get_column_oid(const char *schema, const char *table_name, const char pfree(DatumGetPointer(values[1])); if (schema) pfree(DatumGetPointer(values[2])); - if (ret != SPI_OK_SELECT || SPI_processed == 0) return InvalidOid; + if (ret != SPI_OK_SELECT || SPI_processed == 0) { + if (SPI_tuptable) SPI_freetuptable(SPI_tuptable); + return InvalidOid; + } bool isnull; Datum col_oid = SPI_getbinval(SPI_tuptable->vals[0], SPI_tuptable->tupdesc, 1, &isnull); - if (isnull) return InvalidOid; - - return DatumGetObjectId(col_oid); + Oid result = isnull ? InvalidOid : DatumGetObjectId(col_oid); + SPI_freetuptable(SPI_tuptable); + return result; } // Decode encoded bytea into a pgvalue_t with the decoded base type. @@ -1958,23 +1960,34 @@ Datum cloudsync_col_value(PG_FUNCTION_ARGS) { } // execute vm - Datum d = (Datum)0; int rc = databasevm_step(vm); if (rc == DBRES_DONE) { - rc = DBRES_OK; - PG_RETURN_CSTRING(CLOUDSYNC_RLS_RESTRICTED_VALUE); + databasevm_reset(vm); + // row not found (RLS or genuinely missing) — return the RLS sentinel as bytea + const char *rls = CLOUDSYNC_RLS_RESTRICTED_VALUE; + size_t rls_len = strlen(rls); + bytea *result = (bytea *)palloc(VARHDRSZ + rls_len); + SET_VARSIZE(result, VARHDRSZ + rls_len); + memcpy(VARDATA(result), rls, rls_len); + PG_RETURN_BYTEA_P(result); } else if (rc == DBRES_ROW) { - // store value result - rc = DBRES_OK; - d = database_column_datum(vm, 0); - } - - if (rc != DBRES_OK) { + // copy value before reset invalidates SPI tuple memory + const void *blob = database_column_blob(vm, 0); + int blob_len = database_column_bytes(vm, 0); + bytea *result = NULL; + if (blob && blob_len > 0) { + result = (bytea *)palloc(VARHDRSZ + blob_len); + SET_VARSIZE(result, VARHDRSZ + blob_len); + memcpy(VARDATA(result), blob, blob_len); + } databasevm_reset(vm); - ereport(ERROR, (errmsg("cloudsync_col_value error: %s", cloudsync_errmsg(data)))); + if (result) PG_RETURN_BYTEA_P(result); + PG_RETURN_NULL(); } + databasevm_reset(vm); - PG_RETURN_DATUM(d); + ereport(ERROR, (errmsg("cloudsync_col_value error: %s", cloudsync_errmsg(data)))); + PG_RETURN_NULL(); // unreachable, silences compiler } // Track SRF execution state across calls diff --git a/src/postgresql/database_postgresql.c b/src/postgresql/database_postgresql.c index 03652ed..f777166 100644 --- a/src/postgresql/database_postgresql.c +++ b/src/postgresql/database_postgresql.c @@ -1157,16 +1157,21 @@ int database_create_metatable (cloudsync_context *data, const char *table_name) if (rc != DBRES_OK) { cloudsync_memory_free(meta_ref); return rc; } // Create indices for performance - if (schema) { - sql2 = cloudsync_memory_mprintf( - "CREATE INDEX IF NOT EXISTS \"%s_cloudsync_db_version_idx\" " - "ON \"%s\".\"%s_cloudsync\" (db_version);", - table_name, schema, table_name); - } else { - sql2 = cloudsync_memory_mprintf( - "CREATE INDEX IF NOT EXISTS \"%s_cloudsync_db_version_idx\" " - "ON \"%s_cloudsync\" (db_version);", - table_name, table_name); + { + char escaped_tbl[512], escaped_sch[512]; + sql_escape_identifier(table_name, escaped_tbl, sizeof(escaped_tbl)); + if (schema) { + sql_escape_identifier(schema, escaped_sch, sizeof(escaped_sch)); + sql2 = cloudsync_memory_mprintf( + "CREATE INDEX IF NOT EXISTS \"%s_cloudsync_db_version_idx\" " + "ON \"%s\".\"%s_cloudsync\" (db_version);", + escaped_tbl, escaped_sch, escaped_tbl); + } else { + sql2 = cloudsync_memory_mprintf( + "CREATE INDEX IF NOT EXISTS \"%s_cloudsync_db_version_idx\" " + "ON \"%s_cloudsync\" (db_version);", + escaped_tbl, escaped_tbl); + } } cloudsync_memory_free(meta_ref); if (!sql2) return DBRES_NOMEM; @@ -1558,24 +1563,27 @@ static void database_build_trigger_when( } } + char esc_tbl[512]; + sql_escape_literal(table_name, esc_tbl, sizeof(esc_tbl)); + if (new_filter_str) { snprintf(when_new, when_new_size, "FOR EACH ROW WHEN (cloudsync_is_sync('%s') = false AND (%s))", - table_name, new_filter_str); + esc_tbl, new_filter_str); } else { snprintf(when_new, when_new_size, "FOR EACH ROW WHEN (cloudsync_is_sync('%s') = false)", - table_name); + esc_tbl); } if (old_filter_str) { snprintf(when_old, when_old_size, "FOR EACH ROW WHEN (cloudsync_is_sync('%s') = false AND (%s))", - table_name, old_filter_str); + esc_tbl, old_filter_str); } else { snprintf(when_old, when_old_size, "FOR EACH ROW WHEN (cloudsync_is_sync('%s') = false)", - table_name); + esc_tbl); } if (new_filter_str) cloudsync_memory_free(new_filter_str); @@ -1829,12 +1837,12 @@ int database_pk_names (cloudsync_context *data, const char *table_name, char *** for (uint64_t i = 0; i < n; i++) { HeapTuple tuple = SPI_tuptable->vals[i]; bool isnull; - Datum datum = SPI_getbinval(tuple, SPI_tuptable->tupdesc, 1, &isnull); + SPI_getbinval(tuple, SPI_tuptable->tupdesc, 1, &isnull); if (!isnull) { - // information_schema.column_name is of type 'name', not 'text' - Name namedata = DatumGetName(datum); - char *name = (namedata) ? NameStr(*namedata) : NULL; + // SPI_getvalue returns a palloc'd string regardless of column type + char *name = SPI_getvalue(tuple, SPI_tuptable->tupdesc, 1); pk_names[i] = (name) ? cloudsync_string_dup(name) : NULL; + if (name) pfree(name); } // Cleanup on allocation failure diff --git a/src/postgresql/sql_postgresql.c b/src/postgresql/sql_postgresql.c index fb9ff8c..3af2c8c 100644 --- a/src/postgresql/sql_postgresql.c +++ b/src/postgresql/sql_postgresql.c @@ -283,8 +283,8 @@ const char * const SQL_CLOUDSYNC_UPSERT_COL_INIT_OR_BUMP_VERSION = "INSERT INTO %s (pk, col_name, col_version, db_version, seq, site_id) " "VALUES ($1, '%s', 1, $2, $3, 0) " "ON CONFLICT (pk, col_name) DO UPDATE SET " - "col_version = CASE EXCLUDED.col_version %% 2 WHEN 0 THEN EXCLUDED.col_version + 1 ELSE EXCLUDED.col_version + 2 END, " - "db_version = $2, seq = $3, site_id = 0;"; // TODO: mirror SQLite's bump rules and bind usage + "col_version = CASE %s.col_version %% 2 WHEN 0 THEN %s.col_version + 1 ELSE %s.col_version + 2 END, " + "db_version = $2, seq = $3, site_id = 0;"; const char * const SQL_CLOUDSYNC_UPSERT_RAW_COLVERSION = "INSERT INTO %s (pk, col_name, col_version, db_version, seq, site_id) " diff --git a/src/sqlite/cloudsync_sqlite.c b/src/sqlite/cloudsync_sqlite.c index 556ce08..8157fd6 100644 --- a/src/sqlite/cloudsync_sqlite.c +++ b/src/sqlite/cloudsync_sqlite.c @@ -441,34 +441,48 @@ void dbsync_update_payload_free (cloudsync_update_payload *payload) { int dbsync_update_payload_append (cloudsync_update_payload *payload, sqlite3_value *v1, sqlite3_value *v2, sqlite3_value *v3) { if (payload->count >= payload->capacity) { int newcap = payload->capacity ? payload->capacity * 2 : 128; - + sqlite3_value **new_values_2 = (sqlite3_value **)cloudsync_memory_realloc(payload->new_values, newcap * sizeof(*new_values_2)); if (!new_values_2) return SQLITE_NOMEM; - payload->new_values = new_values_2; - + sqlite3_value **old_values_2 = (sqlite3_value **)cloudsync_memory_realloc(payload->old_values, newcap * sizeof(*old_values_2)); - if (!old_values_2) return SQLITE_NOMEM; + if (!old_values_2) { + // new_values_2 succeeded but old_values failed; keep new_values_2 pointer + // (it's still valid, just larger) but don't update capacity + payload->new_values = new_values_2; + return SQLITE_NOMEM; + } + + payload->new_values = new_values_2; payload->old_values = old_values_2; - payload->capacity = newcap; } - + int index = payload->count; if (payload->table_name == NULL) payload->table_name = database_value_dup(v1); else if (dbutils_value_compare(payload->table_name, v1) != 0) return SQLITE_NOMEM; + payload->new_values[index] = database_value_dup(v2); payload->old_values[index] = database_value_dup(v3); - payload->count++; - - // sanity check memory allocations + + // sanity check memory allocations before committing count bool v1_can_be_null = (database_value_type(v1) == SQLITE_NULL); bool v2_can_be_null = (database_value_type(v2) == SQLITE_NULL); bool v3_can_be_null = (database_value_type(v3) == SQLITE_NULL); - - if ((payload->table_name == NULL) && (!v1_can_be_null)) return SQLITE_NOMEM; - if ((payload->new_values[index] == NULL) && (!v2_can_be_null)) return SQLITE_NOMEM; - if ((payload->old_values[index] == NULL) && (!v3_can_be_null)) return SQLITE_NOMEM; - + + bool oom = false; + if ((payload->table_name == NULL) && (!v1_can_be_null)) oom = true; + if ((payload->new_values[index] == NULL) && (!v2_can_be_null)) oom = true; + if ((payload->old_values[index] == NULL) && (!v3_can_be_null)) oom = true; + + if (oom) { + // clean up partial allocations at this index to prevent leaks + if (payload->new_values[index]) { database_value_free(payload->new_values[index]); payload->new_values[index] = NULL; } + if (payload->old_values[index]) { database_value_free(payload->old_values[index]); payload->old_values[index] = NULL; } + return SQLITE_NOMEM; + } + + payload->count++; return SQLITE_OK; } @@ -498,6 +512,7 @@ void dbsync_update_final (sqlite3_context *context) { cloudsync_table_context *table = table_lookup(data, table_name); if (!table) { dbsync_set_error(context, "Unable to retrieve table name %s in cloudsync_update.", table_name); + dbsync_update_payload_free(payload); return; } @@ -524,6 +539,7 @@ void dbsync_update_final (sqlite3_context *context) { char *pk = pk_encode_prikey((dbvalue_t **)payload->new_values, table_count_pks(table), buffer, &pklen); if (!pk) { sqlite3_result_error(context, "Not enough memory to encode the primary key(s).", -1); + dbsync_update_payload_free(payload); return; } @@ -537,6 +553,7 @@ void dbsync_update_final (sqlite3_context *context) { if (!oldpk) { if (pk != buffer) cloudsync_memory_free(pk); sqlite3_result_error(context, "Not enough memory to encode the primary key(s).", -1); + dbsync_update_payload_free(payload); return; } @@ -893,10 +910,9 @@ void dbsync_payload_load (sqlite3_context *context, int argc, sqlite3_value **ar // MARK: - Register - -int dbsync_register (sqlite3 *db, const char *name, void (*xfunc)(sqlite3_context*,int,sqlite3_value**), void (*xstep)(sqlite3_context*,int,sqlite3_value**), void (*xfinal)(sqlite3_context*), int nargs, char **pzErrMsg, void *ctx, void (*ctx_free)(void *)) { - - const int DEFAULT_FLAGS = SQLITE_UTF8 | SQLITE_INNOCUOUS | SQLITE_DETERMINISTIC; - int rc = sqlite3_create_function_v2(db, name, nargs, DEFAULT_FLAGS, ctx, xfunc, xstep, xfinal, ctx_free); +int dbsync_register_with_flags (sqlite3 *db, const char *name, void (*xfunc)(sqlite3_context*,int,sqlite3_value**), void (*xstep)(sqlite3_context*,int,sqlite3_value**), void (*xfinal)(sqlite3_context*), int nargs, int flags, char **pzErrMsg, void *ctx, void (*ctx_free)(void *)) { + + int rc = sqlite3_create_function_v2(db, name, nargs, flags, ctx, xfunc, xstep, xfinal, ctx_free); if (rc != SQLITE_OK) { if (pzErrMsg) *pzErrMsg = sqlite3_mprintf("Error creating function %s: %s", name, sqlite3_errmsg(db)); @@ -905,11 +921,23 @@ int dbsync_register (sqlite3 *db, const char *name, void (*xfunc)(sqlite3_contex return SQLITE_OK; } +int dbsync_register (sqlite3 *db, const char *name, void (*xfunc)(sqlite3_context*,int,sqlite3_value**), void (*xstep)(sqlite3_context*,int,sqlite3_value**), void (*xfinal)(sqlite3_context*), int nargs, char **pzErrMsg, void *ctx, void (*ctx_free)(void *)) { + const int FLAGS_VOLATILE = SQLITE_UTF8; + DEBUG_DBFUNCTION("dbsync_register %s", name); + return dbsync_register_with_flags(db, name, xfunc, xstep, xfinal, nargs, FLAGS_VOLATILE, pzErrMsg, ctx, ctx_free); +} + int dbsync_register_function (sqlite3 *db, const char *name, void (*xfunc)(sqlite3_context*,int,sqlite3_value**), int nargs, char **pzErrMsg, void *ctx, void (*ctx_free)(void *)) { DEBUG_DBFUNCTION("dbsync_register_function %s", name); return dbsync_register(db, name, xfunc, NULL, NULL, nargs, pzErrMsg, ctx, ctx_free); } +int dbsync_register_pure_function (sqlite3 *db, const char *name, void (*xfunc)(sqlite3_context*,int,sqlite3_value**), int nargs, char **pzErrMsg, void *ctx, void (*ctx_free)(void *)) { + const int FLAGS_PURE = SQLITE_UTF8 | SQLITE_INNOCUOUS | SQLITE_DETERMINISTIC; + DEBUG_DBFUNCTION("dbsync_register_pure_function %s", name); + return dbsync_register_with_flags(db, name, xfunc, NULL, NULL, nargs, FLAGS_PURE, pzErrMsg, ctx, ctx_free); +} + int dbsync_register_aggregate (sqlite3 *db, const char *name, void (*xstep)(sqlite3_context*,int,sqlite3_value**), void (*xfinal)(sqlite3_context*), int nargs, char **pzErrMsg, void *ctx, void (*ctx_free)(void *)) { DEBUG_DBFUNCTION("dbsync_register_aggregate %s", name); return dbsync_register(db, name, NULL, xstep, xfinal, nargs, pzErrMsg, ctx, ctx_free); @@ -999,7 +1027,7 @@ int dbsync_register_functions (sqlite3 *db, char **pzErrMsg) { // register functions // PUBLIC functions - rc = dbsync_register_function(db, "cloudsync_version", dbsync_version, 0, pzErrMsg, ctx, cloudsync_context_free); + rc = dbsync_register_pure_function(db, "cloudsync_version", dbsync_version, 0, pzErrMsg, ctx, cloudsync_context_free); if (rc != SQLITE_OK) return rc; rc = dbsync_register_function(db, "cloudsync_init", dbsync_init1, 1, pzErrMsg, ctx, NULL); @@ -1105,10 +1133,10 @@ int dbsync_register_functions (sqlite3 *db, char **pzErrMsg) { rc = dbsync_register_function(db, "cloudsync_col_value", dbsync_col_value, 3, pzErrMsg, ctx, NULL); if (rc != SQLITE_OK) return rc; - rc = dbsync_register_function(db, "cloudsync_pk_encode", dbsync_pk_encode, -1, pzErrMsg, ctx, NULL); + rc = dbsync_register_pure_function(db, "cloudsync_pk_encode", dbsync_pk_encode, -1, pzErrMsg, ctx, NULL); if (rc != SQLITE_OK) return rc; - - rc = dbsync_register_function(db, "cloudsync_pk_decode", dbsync_pk_decode, 2, pzErrMsg, ctx, NULL); + + rc = dbsync_register_pure_function(db, "cloudsync_pk_decode", dbsync_pk_decode, 2, pzErrMsg, ctx, NULL); if (rc != SQLITE_OK) return rc; rc = dbsync_register_function(db, "cloudsync_seq", dbsync_seq, 0, pzErrMsg, ctx, NULL); diff --git a/src/sqlite/database_sqlite.c b/src/sqlite/database_sqlite.c index c658c4f..82433fe 100644 --- a/src/sqlite/database_sqlite.c +++ b/src/sqlite/database_sqlite.c @@ -565,7 +565,7 @@ int database_cleanup (cloudsync_context *data) { // MARK: - TRIGGERS and META - int database_create_metatable (cloudsync_context *data, const char *table_name) { - DEBUG_DBFUNCTION("database_create_metatable %s", table); + DEBUG_DBFUNCTION("database_create_metatable %s", table_name); // table_name cannot be longer than 512 characters so static buffer size is computed accordling to that value char buffer[2048]; @@ -725,10 +725,10 @@ int database_create_delete_trigger (cloudsync_context *data, const char *table_n // Build trigger WHEN clauses, optionally incorporating a row-level filter. // INSERT/UPDATE use NEW-prefixed filter, DELETE uses OLD-prefixed filter. +// Returns dynamically-allocated strings that must be freed with cloudsync_memory_free. static void database_build_trigger_when( cloudsync_context *data, const char *table_name, const char *filter, - char *when_new, size_t when_new_size, - char *when_old, size_t when_old_size) + char **when_new_out, char **when_old_out) { char *new_filter_str = NULL; char *old_filter_str = NULL; @@ -746,7 +746,11 @@ static void database_build_trigger_when( if (col_rc == SQLITE_OK) { while (sqlite3_step(col_vm) == SQLITE_ROW && ncols < 256) { const char *name = (const char *)sqlite3_column_text(col_vm, 0); - if (name) col_names[ncols++] = cloudsync_memory_mprintf("%s", name); + if (name) { + char *dup = cloudsync_memory_mprintf("%s", name); + if (!dup) break; + col_names[ncols++] = dup; + } } sqlite3_finalize(col_vm); } @@ -759,18 +763,18 @@ static void database_build_trigger_when( } if (new_filter_str) { - sqlite3_snprintf((int)when_new_size, when_new, + *when_new_out = cloudsync_memory_mprintf( "FOR EACH ROW WHEN cloudsync_is_sync('%q') = 0 AND (%s)", table_name, new_filter_str); } else { - sqlite3_snprintf((int)when_new_size, when_new, + *when_new_out = cloudsync_memory_mprintf( "FOR EACH ROW WHEN cloudsync_is_sync('%q') = 0", table_name); } if (old_filter_str) { - sqlite3_snprintf((int)when_old_size, when_old, + *when_old_out = cloudsync_memory_mprintf( "FOR EACH ROW WHEN cloudsync_is_sync('%q') = 0 AND (%s)", table_name, old_filter_str); } else { - sqlite3_snprintf((int)when_old_size, when_old, + *when_old_out = cloudsync_memory_mprintf( "FOR EACH ROW WHEN cloudsync_is_sync('%q') = 0", table_name); } @@ -779,33 +783,40 @@ static void database_build_trigger_when( } int database_create_triggers (cloudsync_context *data, const char *table_name, table_algo algo, const char *filter) { - DEBUG_DBFUNCTION("dbutils_check_triggers %s", table); + DEBUG_DBFUNCTION("database_create_triggers %s", table_name); if (dbutils_settings_check_version(data, "0.8.25") <= 0) { database_delete_triggers(data, table_name); } - char trigger_when_new[4096]; - char trigger_when_old[4096]; + char *trigger_when_new = NULL; + char *trigger_when_old = NULL; database_build_trigger_when(data, table_name, filter, - trigger_when_new, sizeof(trigger_when_new), - trigger_when_old, sizeof(trigger_when_old)); + &trigger_when_new, &trigger_when_old); + + if (!trigger_when_new || !trigger_when_old) { + if (trigger_when_new) cloudsync_memory_free(trigger_when_new); + if (trigger_when_old) cloudsync_memory_free(trigger_when_old); + return SQLITE_NOMEM; + } // INSERT TRIGGER (uses NEW prefix) int rc = database_create_insert_trigger(data, table_name, trigger_when_new); - if (rc != SQLITE_OK) return rc; + if (rc != SQLITE_OK) goto done; // UPDATE TRIGGER (uses NEW prefix) if (algo == table_algo_crdt_gos) rc = database_create_update_trigger_gos(data, table_name); else rc = database_create_update_trigger(data, table_name, trigger_when_new); - if (rc != SQLITE_OK) return rc; + if (rc != SQLITE_OK) goto done; // DELETE TRIGGER (uses OLD prefix) if (algo == table_algo_crdt_gos) rc = database_create_delete_trigger_gos(data, table_name); else rc = database_create_delete_trigger(data, table_name, trigger_when_old); +done: if (rc != SQLITE_OK) DEBUG_ALWAYS("database_create_triggers error %s (%d)", sqlite3_errmsg(cloudsync_db(data)), rc); - + cloudsync_memory_free(trigger_when_new); + cloudsync_memory_free(trigger_when_old); return rc; } @@ -864,7 +875,7 @@ bool database_check_schema_hash (cloudsync_context *data, uint64_t hash) { // the idea is to allow changes on stale peers and to be able to apply these changes on peers with newer schema, // but it requires alter table operation on augmented tables only add new columns and never drop columns for backward compatibility char sql[1024]; - snprintf(sql, sizeof(sql), "SELECT 1 FROM cloudsync_schema_versions WHERE hash = (%" PRIu64 ")", hash); + snprintf(sql, sizeof(sql), "SELECT 1 FROM cloudsync_schema_versions WHERE hash = (%" PRId64 ")", (int64_t)hash); int64_t value = 0; database_select_int(data, sql, &value); @@ -986,9 +997,9 @@ int database_update_schema_hash (cloudsync_context *data, uint64_t *hash) { char sql[1024]; snprintf(sql, sizeof(sql), "INSERT INTO cloudsync_schema_versions (hash, seq) " - "VALUES (%lld, COALESCE((SELECT MAX(seq) FROM cloudsync_schema_versions), 0) + 1) " + "VALUES (%" PRId64 ", COALESCE((SELECT MAX(seq) FROM cloudsync_schema_versions), 0) + 1) " "ON CONFLICT(hash) DO UPDATE SET " - " seq = (SELECT COALESCE(MAX(seq), 0) + 1 FROM cloudsync_schema_versions);", (sqlite3_int64)h); + " seq = (SELECT COALESCE(MAX(seq), 0) + 1 FROM cloudsync_schema_versions);", (int64_t)h); rc = sqlite3_exec(db, sql, NULL, NULL, NULL); if (rc == SQLITE_OK && hash) *hash = h; return rc; @@ -1030,19 +1041,14 @@ static int database_pk_rowid (sqlite3 *db, const char *table_name, char ***names sqlite3_stmt *vm = NULL; int rc = sqlite3_prepare_v2(db, sql, -1, &vm, NULL); if (rc != SQLITE_OK) goto cleanup; - - if (rc == SQLITE_OK) { + + { char **r = (char**)cloudsync_memory_alloc(sizeof(char*)); if (!r) {rc = SQLITE_NOMEM; goto cleanup;} r[0] = cloudsync_string_dup("rowid"); if (!r[0]) {cloudsync_memory_free(r); rc = SQLITE_NOMEM; goto cleanup;} *names = r; *count = 1; - } else { - // WITHOUT ROWID + no declared PKs => return empty set - *names = NULL; - *count = 0; - rc = SQLITE_OK; } cleanup: diff --git a/src/sqlite/sql_sqlite.c b/src/sqlite/sql_sqlite.c index 09f96fe..435111f 100644 --- a/src/sqlite/sql_sqlite.c +++ b/src/sqlite/sql_sqlite.c @@ -42,9 +42,9 @@ const char * const SQL_SETTINGS_LOAD_TABLE = const char * const SQL_CREATE_SETTINGS_TABLE = "CREATE TABLE IF NOT EXISTS cloudsync_settings (key TEXT PRIMARY KEY NOT NULL COLLATE NOCASE, value TEXT);"; -// format strings (snprintf) are also static SQL templates +// format strings (sqlite3_snprintf) are also static SQL templates const char * const SQL_INSERT_SETTINGS_STR_FORMAT = - "INSERT INTO cloudsync_settings (key, value) VALUES ('%s', '%s');"; + "INSERT INTO cloudsync_settings (key, value) VALUES ('%q', '%q');"; const char * const SQL_INSERT_SETTINGS_INT_FORMAT = "INSERT INTO cloudsync_settings (key, value) VALUES ('%s', %lld);"; diff --git a/src/utils.c b/src/utils.c index c4a7219..9fbe12a 100644 --- a/src/utils.c +++ b/src/utils.c @@ -165,7 +165,7 @@ char *cloudsync_string_dup_lowercase (const char *str) { } int cloudsync_blob_compare(const char *blob1, size_t size1, const char *blob2, size_t size2) { - if (size1 != size2) return (int)(size1 - size2); // blobs are different if sizes are different + if (size1 != size2) return (size1 > size2) ? 1 : -1; // blobs are different if sizes are different return memcmp(blob1, blob2, size1); // use memcmp for byte-by-byte comparison } diff --git a/test/unit.c b/test/unit.c index 30d190d..80ac905 100644 --- a/test/unit.c +++ b/test/unit.c @@ -2516,6 +2516,119 @@ bool do_test_hash_function(void) { return true; } +// Test blob compare with large sizes that would overflow old (int)(size1-size2) code +bool do_test_blob_compare_large_sizes(void) { + // The old code did (int)(size1 - size2) which overflows for large size_t values + const char blob1[] = {0x01}; + const char blob2[] = {0x02}; + + // size1 > size2 should give positive result + int r1 = cloudsync_blob_compare(blob1, 100, blob2, 1); + if (r1 <= 0) return false; + + // size1 < size2 should give negative result + int r2 = cloudsync_blob_compare(blob1, 1, blob2, 100); + if (r2 >= 0) return false; + + // Same size, different content + int r3 = cloudsync_blob_compare(blob1, 1, blob2, 1); + if (r3 == 0) return false; + + // Equal + int r4 = cloudsync_blob_compare(blob1, 1, blob1, 1); + if (r4 != 0) return false; + + return true; +} + +// Test that cloudsync_uuid() is non-deterministic (returns different values in same query) +bool do_test_deterministic_flags(void) { + sqlite3 *db = NULL; + sqlite3_stmt *stmt = NULL; + bool result = false; + + int rc = sqlite3_open(":memory:", &db); + if (rc != SQLITE_OK) return false; + + rc = sqlite3_cloudsync_init(db, NULL, NULL); + if (rc != SQLITE_OK) goto cleanup; + + // SELECT cloudsync_uuid(), cloudsync_uuid() — both values should differ + rc = sqlite3_prepare_v2(db, "SELECT cloudsync_uuid(), cloudsync_uuid();", -1, &stmt, NULL); + if (rc != SQLITE_OK) goto cleanup; + + rc = sqlite3_step(stmt); + if (rc != SQLITE_ROW) goto cleanup; + + const char *u1 = (const char *)sqlite3_column_text(stmt, 0); + const char *u2 = (const char *)sqlite3_column_text(stmt, 1); + if (!u1 || !u2) goto cleanup; + + // Non-deterministic: same query, different results + if (strcmp(u1, u2) == 0) goto cleanup; + + result = true; + +cleanup: + if (stmt) sqlite3_finalize(stmt); + if (db) close_db(db); + return result; +} + +// Test schema hash consistency for int64 roundtrip (high-bit values) +bool do_test_schema_hash_consistency(void) { + sqlite3 *db = NULL; + bool result = false; + + int rc = sqlite3_open(":memory:", &db); + if (rc != SQLITE_OK) return false; + + rc = sqlite3_cloudsync_init(db, NULL, NULL); + if (rc != SQLITE_OK) goto cleanup; + + // Create and init a table — use TEXT pk to avoid single INTEGER pk warning + rc = sqlite3_exec(db, "CREATE TABLE t1 (id TEXT PRIMARY KEY NOT NULL, name TEXT);", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto cleanup; + + rc = sqlite3_exec(db, "SELECT cloudsync_init('t1');", NULL, NULL, NULL); + if (rc != SQLITE_OK) goto cleanup; + + // Get the schema hash value by reading cloudsync_schema_versions + { + sqlite3_stmt *stmt = NULL; + rc = sqlite3_prepare_v2(db, "SELECT hash FROM cloudsync_schema_versions ORDER BY seq DESC LIMIT 1;", -1, &stmt, NULL); + if (rc != SQLITE_OK) goto cleanup; + + rc = sqlite3_step(stmt); + if (rc != SQLITE_ROW) { sqlite3_finalize(stmt); goto cleanup; } + + int64_t hash = sqlite3_column_int64(stmt, 0); + sqlite3_finalize(stmt); + + // Verify the hash can be looked up using the same int64 representation + // This tests the PRId64 format consistency fix + char sql[256]; + snprintf(sql, sizeof(sql), "SELECT 1 FROM cloudsync_schema_versions WHERE hash = (%" PRId64 ")", hash); + + stmt = NULL; + rc = sqlite3_prepare_v2(db, sql, -1, &stmt, NULL); + if (rc != SQLITE_OK) goto cleanup; + + rc = sqlite3_step(stmt); + if (rc != SQLITE_ROW) { sqlite3_finalize(stmt); goto cleanup; } + + int found = sqlite3_column_int(stmt, 0); + sqlite3_finalize(stmt); + if (found != 1) goto cleanup; + } + + result = true; + +cleanup: + if (db) close_db(db); + return result; +} + // Test cloudsync_blob_compare function bool do_test_blob_compare(void) { // Test same content, same size @@ -7701,6 +7814,9 @@ int main (int argc, const char * argv[]) { result += test_report("Terminate Test:", do_test_terminate()); result += test_report("Hash Function Test:", do_test_hash_function()); result += test_report("Blob Compare Test:", do_test_blob_compare()); + result += test_report("Blob Compare Large:", do_test_blob_compare_large_sizes()); + result += test_report("Deterministic Flags:", do_test_deterministic_flags()); + result += test_report("Schema Hash Roundtrip:", do_test_schema_hash_consistency()); result += test_report("String Functions Test:", do_test_string_functions()); result += test_report("UUID Functions Test:", do_test_uuid_functions()); result += test_report("RowID Decode Test:", do_test_rowid_decode()); From fcfc9eca8a1c00191407d3d189a7b51769b68955 Mon Sep 17 00:00:00 2001 From: Marco Bambini Date: Sat, 21 Feb 2026 16:31:30 +0100 Subject: [PATCH 2/3] Bump version to 0.9.111 Co-Authored-By: Claude Opus 4.6 --- src/cloudsync.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cloudsync.h b/src/cloudsync.h index c882057..8985432 100644 --- a/src/cloudsync.h +++ b/src/cloudsync.h @@ -17,7 +17,7 @@ extern "C" { #endif -#define CLOUDSYNC_VERSION "0.9.110" +#define CLOUDSYNC_VERSION "0.9.111" #define CLOUDSYNC_MAX_TABLENAME_LEN 512 #define CLOUDSYNC_VALUE_NOTSET -1 From 1e9885ab9d4137d6a1b9fea76db8cc036e0b228f Mon Sep 17 00:00:00 2001 From: Marco Bambini Date: Sat, 21 Feb 2026 16:43:06 +0100 Subject: [PATCH 3/3] Update .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 646d00e..85541ce 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,4 @@ jniLibs/ .DS_Store Thumbs.db CLAUDE.md +*.o