From 5786d7f4b6f135d53a4ae78b4a781a2cc922bd15 Mon Sep 17 00:00:00 2001 From: Taylor Eernisse Date: Sun, 8 Feb 2026 07:55:54 -0500 Subject: [PATCH] =?UTF-8?q?fix:=20defensive=20hardening=20=E2=80=94=20lock?= =?UTF-8?q?=20release=20logging,=20SQLite=20param=20guard,=20vector=20cast?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three defensive improvements found via peer code review: 1. lock.rs: Lock release errors were silently discarded with `let _ =`. If the DELETE failed (disk full, corruption), the lock stayed in the database with no diagnostic. Next sync would require --force with no clue why. Now logs with error!() including the underlying error message. 2. filters.rs: Dynamic SQL label filter construction had no upper bound on bind parameters. With many combined filters, param_idx + labels.len() could exceed SQLite's 999-parameter limit, producing an opaque error. Added a guard that caps labels at 900 - param_idx. 3. vector.rs: max_chunks_per_document returned i64 which was cast to usize. A negative value from a corrupt database would wrap to a huge number, causing overflow in the multiplier calculation. Now clamped to .max(1) and cast via unsigned_abs(). Co-Authored-By: Claude Opus 4.6 --- src/core/lock.rs | 13 +++++++++---- src/search/filters.rs | 13 ++++++++++--- src/search/vector.rs | 4 ++-- 3 files changed, 21 insertions(+), 9 deletions(-) diff --git a/src/core/lock.rs b/src/core/lock.rs index 8abd886..2e43ed2 100644 --- a/src/core/lock.rs +++ b/src/core/lock.rs @@ -121,12 +121,17 @@ impl AppLock { let _ = handle.join(); } - let _ = self.conn.execute( + match self.conn.execute( "DELETE FROM app_locks WHERE name = ? AND owner = ?", (&self.name, &self.owner), - ); - - info!(owner = %self.owner, "Lock released"); + ) { + Ok(_) => info!(owner = %self.owner, "Lock released"), + Err(e) => error!( + owner = %self.owner, + error = %e, + "Failed to release lock; may require --force on next sync" + ), + } } fn start_heartbeat(&mut self) { diff --git a/src/search/filters.rs b/src/search/filters.rs index c615fe8..0d6465a 100644 --- a/src/search/filters.rs +++ b/src/search/filters.rs @@ -98,15 +98,22 @@ pub fn apply_filters( } if !filters.labels.is_empty() { - let placeholders: Vec = (0..filters.labels.len()) + // SQLite has a default limit of 999 bind parameters. + let max_labels = 900_usize.saturating_sub(param_idx); + let label_slice = if filters.labels.len() > max_labels { + &filters.labels[..max_labels] + } else { + &filters.labels + }; + let placeholders: Vec = (0..label_slice.len()) .map(|i| format!("?{}", param_idx + i)) .collect(); sql.push_str(&format!( " AND EXISTS (SELECT 1 FROM document_labels dl WHERE dl.document_id = d.id AND dl.label_name IN ({}) GROUP BY dl.document_id HAVING COUNT(DISTINCT dl.label_name) = {})", placeholders.join(","), - filters.labels.len() + label_slice.len() )); - for label in &filters.labels { + for label in label_slice { params.push(Box::new(label.clone())); param_idx += 1; } diff --git a/src/search/vector.rs b/src/search/vector.rs index d60863e..bb09ec8 100644 --- a/src/search/vector.rs +++ b/src/search/vector.rs @@ -50,8 +50,8 @@ pub fn search_vector( .flat_map(|f| f.to_le_bytes()) .collect(); - let max_chunks = max_chunks_per_document(conn); - let multiplier = ((max_chunks as usize * 3 / 2) + 1).max(8); + let max_chunks = max_chunks_per_document(conn).max(1); + let multiplier = ((max_chunks.unsigned_abs() as usize * 3 / 2) + 1).max(8); let k = limit * multiplier; let mut stmt = conn.prepare(