@@ -948,6 +948,107 @@ bool cbm_store_check_integrity_deep(cbm_store_t *s) {
948948 return ok ;
949949}
950950
951+ /* Classify a SQLite result code as a transient (retryable) condition vs. a
952+ * hard error. SQLITE_BUSY / SQLITE_LOCKED happen when another connection holds
953+ * the writer lock — they are NOT evidence of corruption, yet the bare
954+ * cbm_store_check_integrity() treats any prepare failure as "corrupt". This
955+ * helper backs the verdict API so the quarantine path stops destroying healthy
956+ * DBs that merely lost a lock race (#1206, #1037). */
957+ static bool st_rc_is_transient (int rc ) {
958+ switch (rc ) {
959+ case SQLITE_BUSY :
960+ case SQLITE_LOCKED :
961+ case SQLITE_IOERR_LOCK :
962+ case SQLITE_IOERR_BLOCKED :
963+ return true;
964+ default :
965+ return false;
966+ }
967+ }
968+
969+ cbm_integrity_verdict_t cbm_store_check_integrity_verdict (cbm_store_t * s ) {
970+ if (!s || !s -> db ) {
971+ /* No handle at all — the caller could not even open the file. That is
972+ * an open problem, not a corruption verdict, so the DB must not be
973+ * quarantined (renaming it would discard a file we never read). */
974+ return CBM_INTEGRITY_TRANSIENT ;
975+ }
976+
977+ /* ── Shallow check (projects table shape + root_path sanity) ── */
978+ sqlite3_stmt * stmt = NULL ;
979+ int rc =
980+ sqlite3_prepare_v2 (s -> db , "SELECT count(*) FROM projects;" , CBM_NOT_FOUND , & stmt , NULL );
981+ if (rc != SQLITE_OK ) {
982+ /* A prepare failure here is the #1206 trigger: under concurrent access
983+ * the schema lookup can return BUSY/LOCKED. Treat transient codes as
984+ * "do not quarantine"; only a hard error is corruption evidence. */
985+ return st_rc_is_transient (rc ) ? CBM_INTEGRITY_TRANSIENT : CBM_INTEGRITY_CORRUPT ;
986+ }
987+ cbm_integrity_verdict_t verdict = CBM_INTEGRITY_OK ;
988+ if (sqlite3_step (stmt ) == SQLITE_ROW ) {
989+ int row_count = sqlite3_column_int (stmt , 0 );
990+ if (row_count > ST_MAX_ROW_CHECK ) {
991+ (void )fprintf (stderr , "ERROR store.corrupt table=projects rows=%d (expected 1)\n" ,
992+ row_count );
993+ verdict = CBM_INTEGRITY_CORRUPT ;
994+ }
995+ } else {
996+ /* step returned DONE/ERROR — for a SELECT count(*) this means the table
997+ * is unreadable. BUSY-family => transient; otherwise corruption. */
998+ int step_rc = sqlite3_errcode (s -> db );
999+ verdict = st_rc_is_transient (step_rc ) ? CBM_INTEGRITY_TRANSIENT : CBM_INTEGRITY_CORRUPT ;
1000+ }
1001+ sqlite3_finalize (stmt );
1002+ if (verdict != CBM_INTEGRITY_OK ) {
1003+ return verdict ;
1004+ }
1005+
1006+ /* root_path sanity — same logic as cbm_store_check_integrity, but classify
1007+ * prepare failures as transient vs. corrupt rather than a blanket false. */
1008+ rc = sqlite3_prepare_v2 (s -> db ,
1009+ "SELECT root_path FROM projects WHERE root_path != '' "
1010+ "AND NOT (substr(root_path, 1, 1) = '/' "
1011+ "OR (substr(root_path, 1, 1) BETWEEN 'A' AND 'Z') "
1012+ "OR (substr(root_path, 1, 1) BETWEEN 'a' AND 'z')) LIMIT 1;" ,
1013+ CBM_NOT_FOUND , & stmt , NULL );
1014+ if (rc != SQLITE_OK ) {
1015+ return st_rc_is_transient (rc ) ? CBM_INTEGRITY_TRANSIENT : CBM_INTEGRITY_CORRUPT ;
1016+ }
1017+ if (sqlite3_step (stmt ) == SQLITE_ROW ) {
1018+ const char * bad_path = (const char * )sqlite3_column_text (stmt , 0 );
1019+ (void )fprintf (stderr , "ERROR store.corrupt table=projects bad_root_path=%s\n" ,
1020+ bad_path ? bad_path : "(null)" );
1021+ verdict = CBM_INTEGRITY_CORRUPT ;
1022+ }
1023+ sqlite3_finalize (stmt );
1024+ if (verdict != CBM_INTEGRITY_OK ) {
1025+ return verdict ;
1026+ }
1027+
1028+ /* ── Deep check: walk the btrees for page-level damage (#1037) ──
1029+ * The shallow check above passes for a DB whose projects table is intact
1030+ * but whose node/edge btrees are torn (observed: 185k nodes / 6k edges with
1031+ * PRAGMA integrity_check reporting btreeInitPage errors). Only quick_check
1032+ * catches that. quick_check is O(db size); this function runs only on the
1033+ * quarantine decision path, never on hot opens. */
1034+ rc = sqlite3_prepare_v2 (s -> db , "PRAGMA quick_check(1);" , CBM_NOT_FOUND , & stmt , NULL );
1035+ if (rc != SQLITE_OK ) {
1036+ return st_rc_is_transient (rc ) ? CBM_INTEGRITY_TRANSIENT : CBM_INTEGRITY_CORRUPT ;
1037+ }
1038+ if (sqlite3_step (stmt ) == SQLITE_ROW ) {
1039+ const char * res = (const char * )sqlite3_column_text (stmt , 0 );
1040+ if (!res || strcmp (res , "ok" ) != 0 ) {
1041+ (void )fprintf (stderr , "ERROR store.corrupt quick_check=%s\n" , res ? res : "(null)" );
1042+ verdict = CBM_INTEGRITY_CORRUPT ;
1043+ }
1044+ } else {
1045+ int step_rc = sqlite3_errcode (s -> db );
1046+ verdict = st_rc_is_transient (step_rc ) ? CBM_INTEGRITY_TRANSIENT : CBM_INTEGRITY_CORRUPT ;
1047+ }
1048+ sqlite3_finalize (stmt );
1049+ return verdict ;
1050+ }
1051+
9511052bool cbm_store_check_integrity (cbm_store_t * s ) {
9521053 if (!s || !s -> db ) {
9531054 return false;
0 commit comments