mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
fix: review fixes for libSQL backend (shared connections, panics, indexes)
- Replace .expect() with proper error propagation in 3 call sites - Share Arc<Database> between backend and stores instead of single Connection - Add connect-per-operation pattern to LibSqlSecretsStore and LibSqlWasmToolStore - Wrap store() INSERT + SELECT-back in a transaction - Add ~22 missing indexes for parity with PostgreSQL schema - Add 18 leak_detection_patterns seed rows matching PostgreSQL V2 migration - Fix super:: import to use crate:: style - Gate mask_password_in_url behind #[cfg(feature = "postgres")] - Rewrite secrets store init with or_else chain for runtime backend selection Co-Authored-By: Claude Opus 4.6 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
5814d77b16
commit
09e6a7e6d8
+4
-8
@@ -530,11 +530,9 @@ async fn get_secrets_store() -> anyhow::Result<Arc<dyn SecretsStore + Send + Syn
|
||||
.unwrap_or(&default_path);
|
||||
|
||||
let backend = if let Some(ref url) = config.database.libsql_url {
|
||||
let token = config
|
||||
.database
|
||||
.libsql_auth_token
|
||||
.as_ref()
|
||||
.expect("LIBSQL_AUTH_TOKEN required when LIBSQL_URL is set");
|
||||
let token = config.database.libsql_auth_token.as_ref().ok_or_else(|| {
|
||||
anyhow::anyhow!("LIBSQL_AUTH_TOKEN is required when LIBSQL_URL is set")
|
||||
})?;
|
||||
LibSqlBackend::new_remote_replica(db_path, url, token.expose_secret())
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{}", e))?
|
||||
@@ -548,10 +546,8 @@ async fn get_secrets_store() -> anyhow::Result<Arc<dyn SecretsStore + Send + Syn
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{}", e))?;
|
||||
|
||||
let conn = backend.connect().map_err(|e| anyhow::anyhow!("{}", e))?;
|
||||
|
||||
return Ok(Arc::new(crate::secrets::LibSqlSecretsStore::new(
|
||||
conn,
|
||||
backend.shared_db(),
|
||||
Arc::new(crypto),
|
||||
)));
|
||||
}
|
||||
|
||||
+4
-8
@@ -748,11 +748,9 @@ async fn auth_tool(name: String, dir: Option<PathBuf>, user_id: String) -> anyho
|
||||
.unwrap_or(&default_path);
|
||||
|
||||
let backend = if let Some(ref url) = config.database.libsql_url {
|
||||
let token = config
|
||||
.database
|
||||
.libsql_auth_token
|
||||
.as_ref()
|
||||
.expect("LIBSQL_AUTH_TOKEN required when LIBSQL_URL is set");
|
||||
let token = config.database.libsql_auth_token.as_ref().ok_or_else(|| {
|
||||
anyhow::anyhow!("LIBSQL_AUTH_TOKEN is required when LIBSQL_URL is set")
|
||||
})?;
|
||||
LibSqlBackend::new_remote_replica(db_path, url, token.expose_secret())
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{}", e))?
|
||||
@@ -766,10 +764,8 @@ async fn auth_tool(name: String, dir: Option<PathBuf>, user_id: String) -> anyho
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("{}", e))?;
|
||||
|
||||
let conn = backend.connect().map_err(|e| anyhow::anyhow!("{}", e))?;
|
||||
|
||||
Arc::new(crate::secrets::LibSqlSecretsStore::new(
|
||||
conn,
|
||||
backend.shared_db(),
|
||||
Arc::new(crypto),
|
||||
))
|
||||
}
|
||||
|
||||
+18
-13
@@ -8,6 +8,7 @@
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, NaiveDateTime, Utc};
|
||||
@@ -31,7 +32,7 @@ use crate::workspace::{
|
||||
reciprocal_rank_fusion,
|
||||
};
|
||||
|
||||
use super::libsql_migrations;
|
||||
use crate::db::libsql_migrations;
|
||||
|
||||
/// Explicit column list for routines table (matches positional access in `row_to_routine_libsql`).
|
||||
const ROUTINE_COLUMNS: &str = "\
|
||||
@@ -48,8 +49,12 @@ const ROUTINE_RUN_COLUMNS: &str = "\
|
||||
status, completed_at, result_summary, tokens_used, job_id, created_at";
|
||||
|
||||
/// libSQL/Turso database backend.
|
||||
///
|
||||
/// Stores the `Database` handle in an `Arc` so that the same underlying
|
||||
/// database can be shared with stores (SecretsStore, WasmToolStore) that
|
||||
/// create their own connections per-operation.
|
||||
pub struct LibSqlBackend {
|
||||
db: LibSqlDatabase,
|
||||
db: Arc<LibSqlDatabase>,
|
||||
}
|
||||
|
||||
impl LibSqlBackend {
|
||||
@@ -67,7 +72,7 @@ impl LibSqlBackend {
|
||||
.await
|
||||
.map_err(|e| DatabaseError::Pool(format!("Failed to open libSQL database: {}", e)))?;
|
||||
|
||||
Ok(Self { db })
|
||||
Ok(Self { db: Arc::new(db) })
|
||||
}
|
||||
|
||||
/// Create a new in-memory database (for testing).
|
||||
@@ -79,7 +84,7 @@ impl LibSqlBackend {
|
||||
DatabaseError::Pool(format!("Failed to create in-memory database: {}", e))
|
||||
})?;
|
||||
|
||||
Ok(Self { db })
|
||||
Ok(Self { db: Arc::new(db) })
|
||||
}
|
||||
|
||||
/// Create with Turso cloud sync (embedded replica).
|
||||
@@ -99,18 +104,18 @@ impl LibSqlBackend {
|
||||
.await
|
||||
.map_err(|e| DatabaseError::Pool(format!("Failed to open remote replica: {}", e)))?;
|
||||
|
||||
Ok(Self { db })
|
||||
Ok(Self { db: Arc::new(db) })
|
||||
}
|
||||
|
||||
/// Get the underlying database handle (for sync operations).
|
||||
pub fn database(&self) -> &LibSqlDatabase {
|
||||
&self.db
|
||||
}
|
||||
|
||||
/// Create a new connection to the same database.
|
||||
/// Get a shared reference to the underlying database handle.
|
||||
///
|
||||
/// Used for creating separate connections for SecretsStore and WasmToolStore
|
||||
/// which have their own connection needs.
|
||||
/// Use this to pass the database to stores (SecretsStore, WasmToolStore)
|
||||
/// that need to create their own connections per-operation.
|
||||
pub fn shared_db(&self) -> Arc<LibSqlDatabase> {
|
||||
Arc::clone(&self.db)
|
||||
}
|
||||
|
||||
/// Create a new connection to the database.
|
||||
pub fn connect(&self) -> Result<Connection, DatabaseError> {
|
||||
self.db
|
||||
.connect()
|
||||
|
||||
@@ -477,4 +477,73 @@ CREATE TABLE IF NOT EXISTS settings (
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_settings_user ON settings(user_id);
|
||||
|
||||
-- ==================== Missing indexes (parity with PostgreSQL) ====================
|
||||
|
||||
-- agent_jobs
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_jobs_stuck ON agent_jobs(stuck_since);
|
||||
|
||||
-- secrets
|
||||
CREATE INDEX IF NOT EXISTS idx_secrets_provider ON secrets(provider);
|
||||
CREATE INDEX IF NOT EXISTS idx_secrets_expires ON secrets(expires_at);
|
||||
|
||||
-- wasm_tools
|
||||
CREATE INDEX IF NOT EXISTS idx_wasm_tools_trust ON wasm_tools(trust_level);
|
||||
|
||||
-- tool_capabilities
|
||||
CREATE INDEX IF NOT EXISTS idx_tool_capabilities_tool ON tool_capabilities(wasm_tool_id);
|
||||
|
||||
-- leak_detection_patterns
|
||||
CREATE INDEX IF NOT EXISTS idx_leak_patterns_enabled ON leak_detection_patterns(enabled);
|
||||
|
||||
-- tool_rate_limit_state
|
||||
CREATE INDEX IF NOT EXISTS idx_rate_limit_tool ON tool_rate_limit_state(wasm_tool_id);
|
||||
|
||||
-- secret_usage_log
|
||||
CREATE INDEX IF NOT EXISTS idx_secret_usage_secret ON secret_usage_log(secret_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_secret_usage_tool ON secret_usage_log(wasm_tool_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_secret_usage_created ON secret_usage_log(created_at DESC);
|
||||
|
||||
-- leak_detection_events
|
||||
CREATE INDEX IF NOT EXISTS idx_leak_events_pattern ON leak_detection_events(pattern_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_leak_events_tool ON leak_detection_events(wasm_tool_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_leak_events_user ON leak_detection_events(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_leak_events_created ON leak_detection_events(created_at DESC);
|
||||
|
||||
-- tool_failures
|
||||
CREATE INDEX IF NOT EXISTS idx_tool_failures_count ON tool_failures(error_count DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_tool_failures_unrepaired ON tool_failures(tool_name);
|
||||
|
||||
-- routines
|
||||
CREATE INDEX IF NOT EXISTS idx_routines_next_fire ON routines(next_fire_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_routines_event_triggers ON routines(user_id);
|
||||
|
||||
-- routine_runs
|
||||
CREATE INDEX IF NOT EXISTS idx_routine_runs_status ON routine_runs(status);
|
||||
|
||||
-- heartbeat_state
|
||||
CREATE INDEX IF NOT EXISTS idx_heartbeat_next_run ON heartbeat_state(next_run);
|
||||
|
||||
-- ==================== Seed data ====================
|
||||
|
||||
-- Pre-populate leak detection patterns (matches PostgreSQL V2 migration).
|
||||
INSERT OR IGNORE INTO leak_detection_patterns (id, name, pattern, severity, action, enabled, created_at) VALUES
|
||||
('550e8400-e29b-41d4-a716-446655440001', 'openai_api_key', 'sk-(?:proj-)?[a-zA-Z0-9]{20,}(?:T3BlbkFJ[a-zA-Z0-9_-]*)?', 'critical', 'block', 1, datetime('now')),
|
||||
('550e8400-e29b-41d4-a716-446655440002', 'anthropic_api_key', 'sk-ant-api[a-zA-Z0-9_-]{90,}', 'critical', 'block', 1, datetime('now')),
|
||||
('550e8400-e29b-41d4-a716-446655440003', 'aws_access_key', 'AKIA[0-9A-Z]{16}', 'critical', 'block', 1, datetime('now')),
|
||||
('550e8400-e29b-41d4-a716-446655440004', 'aws_secret_key', '(?<![A-Za-z0-9/+=])[A-Za-z0-9/+=]{40}(?![A-Za-z0-9/+=])', 'high', 'block', 1, datetime('now')),
|
||||
('550e8400-e29b-41d4-a716-446655440005', 'github_token', 'gh[pousr]_[A-Za-z0-9_]{36,}', 'critical', 'block', 1, datetime('now')),
|
||||
('550e8400-e29b-41d4-a716-446655440006', 'github_fine_grained_pat', 'github_pat_[a-zA-Z0-9]{22}_[a-zA-Z0-9]{59}', 'critical', 'block', 1, datetime('now')),
|
||||
('550e8400-e29b-41d4-a716-446655440007', 'stripe_api_key', 'sk_(?:live|test)_[a-zA-Z0-9]{24,}', 'critical', 'block', 1, datetime('now')),
|
||||
('550e8400-e29b-41d4-a716-446655440008', 'nearai_session', 'sess_[a-zA-Z0-9]{32,}', 'critical', 'block', 1, datetime('now')),
|
||||
('550e8400-e29b-41d4-a716-446655440009', 'bearer_token', 'Bearer\s+[a-zA-Z0-9_-]{20,}', 'high', 'redact', 1, datetime('now')),
|
||||
('550e8400-e29b-41d4-a716-44665544000a', 'pem_private_key', '-----BEGIN\s+(?:RSA\s+)?PRIVATE\s+KEY-----', 'critical', 'block', 1, datetime('now')),
|
||||
('550e8400-e29b-41d4-a716-44665544000b', 'ssh_private_key', '-----BEGIN\s+(?:OPENSSH|EC|DSA)\s+PRIVATE\s+KEY-----', 'critical', 'block', 1, datetime('now')),
|
||||
('550e8400-e29b-41d4-a716-44665544000c', 'google_api_key', 'AIza[0-9A-Za-z_-]{35}', 'high', 'block', 1, datetime('now')),
|
||||
('550e8400-e29b-41d4-a716-44665544000d', 'slack_token', 'xox[baprs]-[0-9a-zA-Z-]{10,}', 'high', 'block', 1, datetime('now')),
|
||||
('550e8400-e29b-41d4-a716-44665544000e', 'discord_token', '[MN][A-Za-z\d]{23,}\.[\w-]{6}\.[\w-]{27}', 'high', 'block', 1, datetime('now')),
|
||||
('550e8400-e29b-41d4-a716-44665544000f', 'twilio_api_key', 'SK[a-fA-F0-9]{32}', 'high', 'block', 1, datetime('now')),
|
||||
('550e8400-e29b-41d4-a716-446655440010', 'sendgrid_api_key', 'SG\.[a-zA-Z0-9_-]{22}\.[a-zA-Z0-9_-]{43}', 'high', 'block', 1, datetime('now')),
|
||||
('550e8400-e29b-41d4-a716-446655440011', 'mailchimp_api_key', '[a-f0-9]{32}-us[0-9]{1,2}', 'medium', 'block', 1, datetime('now')),
|
||||
('550e8400-e29b-41d4-a716-446655440012', 'high_entropy_hex', '(?<![a-fA-F0-9])[a-fA-F0-9]{64}(?![a-fA-F0-9])', 'medium', 'warn', 1, datetime('now'));
|
||||
|
||||
"#;
|
||||
|
||||
+47
-46
@@ -38,6 +38,8 @@ use ironclaw::{
|
||||
workspace::{EmbeddingProvider, NearAiEmbeddings, OpenAiEmbeddings, Workspace},
|
||||
};
|
||||
|
||||
#[cfg(feature = "libsql")]
|
||||
use ironclaw::secrets::LibSqlSecretsStore;
|
||||
#[cfg(feature = "postgres")]
|
||||
use ironclaw::secrets::PostgresSecretsStore;
|
||||
use ironclaw::secrets::SecretsCrypto;
|
||||
@@ -344,12 +346,12 @@ async fn main() -> anyhow::Result<()> {
|
||||
//
|
||||
// NOTE: For simpler call sites (CLI commands, Memory handler) use the shared
|
||||
// helper `ironclaw::db::connect_from_config()`. This block is kept inline
|
||||
// because it also captures backend-specific handles (`pg_pool`, `libsql_conn`)
|
||||
// because it also captures backend-specific handles (`pg_pool`, `libsql_db`)
|
||||
// needed by the secrets store.
|
||||
#[cfg(feature = "postgres")]
|
||||
let mut pg_pool: Option<deadpool_postgres::Pool> = None;
|
||||
#[cfg(feature = "libsql")]
|
||||
let mut libsql_conn: Option<libsql::Connection> = None;
|
||||
let mut libsql_db: Option<std::sync::Arc<libsql::Database>> = None;
|
||||
|
||||
let db: Option<Arc<dyn ironclaw::db::Database>> = if cli.no_db {
|
||||
tracing::warn!("Running without database connection");
|
||||
@@ -370,11 +372,9 @@ async fn main() -> anyhow::Result<()> {
|
||||
.unwrap_or(&default_path);
|
||||
|
||||
let backend = if let Some(ref url) = config.database.libsql_url {
|
||||
let token = config
|
||||
.database
|
||||
.libsql_auth_token
|
||||
.as_ref()
|
||||
.expect("LIBSQL_AUTH_TOKEN required when LIBSQL_URL is set");
|
||||
let token = config.database.libsql_auth_token.as_ref().ok_or_else(|| {
|
||||
anyhow::anyhow!("LIBSQL_AUTH_TOKEN is required when LIBSQL_URL is set")
|
||||
})?;
|
||||
LibSqlBackend::new_remote_replica(db_path, url, token.expose_secret()).await?
|
||||
} else {
|
||||
LibSqlBackend::new_local(db_path).await?
|
||||
@@ -382,8 +382,8 @@ async fn main() -> anyhow::Result<()> {
|
||||
backend.run_migrations().await?;
|
||||
tracing::info!("libSQL database connected and migrations applied");
|
||||
|
||||
// Capture an extra connection for SecretsStore / WasmToolStore
|
||||
libsql_conn = Some(backend.connect().map_err(|e| anyhow::anyhow!("{}", e))?);
|
||||
// Capture the Database handle for SecretsStore (connection-per-op)
|
||||
libsql_db = Some(backend.shared_db());
|
||||
|
||||
Some(Arc::new(backend) as Arc<dyn ironclaw::db::Database>)
|
||||
}
|
||||
@@ -516,47 +516,48 @@ async fn main() -> anyhow::Result<()> {
|
||||
tracing::info!("Builder mode enabled");
|
||||
}
|
||||
|
||||
// Create secrets store if master key is configured (needed for MCP auth and WASM channels)
|
||||
let secrets_store: Option<Arc<dyn SecretsStore + Send + Sync>> = {
|
||||
#[cfg(feature = "postgres")]
|
||||
{
|
||||
if let (Some(pool), Some(master_key)) = (&pg_pool, config.secrets.master_key()) {
|
||||
match SecretsCrypto::new(master_key.clone()) {
|
||||
Ok(crypto) => Some(Arc::new(PostgresSecretsStore::new(
|
||||
pool.clone(),
|
||||
Arc::new(crypto),
|
||||
))),
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to initialize secrets crypto: {}", e);
|
||||
None
|
||||
}
|
||||
// Create secrets store if master key is configured (needed for MCP auth and WASM channels).
|
||||
//
|
||||
// When both `postgres` and `libsql` features are compiled, the runtime-selected
|
||||
// backend determines which store is created: whichever DB init branch ran will
|
||||
// have set its handle (pg_pool or libsql_db), and the or_else chain picks it up.
|
||||
let secrets_store: Option<Arc<dyn SecretsStore + Send + Sync>> =
|
||||
if let Some(master_key) = config.secrets.master_key() {
|
||||
match SecretsCrypto::new(master_key.clone()) {
|
||||
Ok(crypto) => {
|
||||
let crypto = Arc::new(crypto);
|
||||
let store: Option<Arc<dyn SecretsStore + Send + Sync>> = None;
|
||||
|
||||
#[cfg(feature = "libsql")]
|
||||
let store = store.or_else(|| {
|
||||
libsql_db.take().map(|db| {
|
||||
Arc::new(LibSqlSecretsStore::new(db, Arc::clone(&crypto)))
|
||||
as Arc<dyn SecretsStore + Send + Sync>
|
||||
})
|
||||
});
|
||||
|
||||
#[cfg(feature = "postgres")]
|
||||
let store = store.or_else(|| {
|
||||
pg_pool.as_ref().map(|pool| {
|
||||
Arc::new(PostgresSecretsStore::new(pool.clone(), Arc::clone(&crypto)))
|
||||
as Arc<dyn SecretsStore + Send + Sync>
|
||||
})
|
||||
});
|
||||
|
||||
store
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
#[cfg(all(feature = "libsql", not(feature = "postgres")))]
|
||||
{
|
||||
if let (Some(conn), Some(master_key)) =
|
||||
(libsql_conn.take(), config.secrets.master_key())
|
||||
{
|
||||
match SecretsCrypto::new(master_key.clone()) {
|
||||
Ok(crypto) => Some(Arc::new(LibSqlSecretsStore::new(conn, Arc::new(crypto)))
|
||||
as Arc<dyn SecretsStore + Send + Sync>),
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to initialize secrets crypto: {}", e);
|
||||
None
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to initialize secrets crypto: {}", e);
|
||||
#[cfg(feature = "libsql")]
|
||||
let _ = libsql_db.take();
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
#[cfg(not(any(feature = "postgres", feature = "libsql")))]
|
||||
{
|
||||
} else {
|
||||
#[cfg(feature = "libsql")]
|
||||
let _ = libsql_db.take();
|
||||
None
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
let mcp_session_manager = Arc::new(McpSessionManager::new());
|
||||
|
||||
|
||||
+30
-21
@@ -307,17 +307,26 @@ fn row_to_secret(row: &tokio_postgres::Row) -> Secret {
|
||||
// ==================== libSQL implementation ====================
|
||||
|
||||
/// libSQL/Turso implementation of SecretsStore.
|
||||
///
|
||||
/// Holds an `Arc<Database>` handle and creates a fresh connection per operation,
|
||||
/// matching the connection-per-request pattern used by the main `LibSqlBackend`.
|
||||
#[cfg(feature = "libsql")]
|
||||
pub struct LibSqlSecretsStore {
|
||||
conn: libsql::Connection,
|
||||
db: Arc<libsql::Database>,
|
||||
crypto: Arc<SecretsCrypto>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "libsql")]
|
||||
impl LibSqlSecretsStore {
|
||||
/// Create a new store with the given libsql connection and crypto instance.
|
||||
pub fn new(conn: libsql::Connection, crypto: Arc<SecretsCrypto>) -> Self {
|
||||
Self { conn, crypto }
|
||||
/// Create a new store with the given shared libsql database handle and crypto instance.
|
||||
pub fn new(db: Arc<libsql::Database>, crypto: Arc<SecretsCrypto>) -> Self {
|
||||
Self { db, crypto }
|
||||
}
|
||||
|
||||
fn connect(&self) -> Result<libsql::Connection, SecretError> {
|
||||
self.db
|
||||
.connect()
|
||||
.map_err(|e| SecretError::Database(format!("Connection failed: {}", e)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -340,8 +349,8 @@ impl SecretsStore for LibSqlSecretsStore {
|
||||
.map(|dt| dt.to_rfc3339_opts(chrono::SecondsFormat::Millis, true));
|
||||
|
||||
// Start transaction for atomic upsert + read-back
|
||||
let tx = self
|
||||
.conn
|
||||
let conn = self.connect()?;
|
||||
let tx = conn
|
||||
.transaction()
|
||||
.await
|
||||
.map_err(|e| SecretError::Database(e.to_string()))?;
|
||||
@@ -401,8 +410,8 @@ impl SecretsStore for LibSqlSecretsStore {
|
||||
}
|
||||
|
||||
async fn get(&self, user_id: &str, name: &str) -> Result<Secret, SecretError> {
|
||||
let mut rows = self
|
||||
.conn
|
||||
let conn = self.connect()?;
|
||||
let mut rows = conn
|
||||
.query(
|
||||
r#"
|
||||
SELECT id, user_id, name, encrypted_value, key_salt, provider, expires_at,
|
||||
@@ -446,8 +455,8 @@ impl SecretsStore for LibSqlSecretsStore {
|
||||
}
|
||||
|
||||
async fn exists(&self, user_id: &str, name: &str) -> Result<bool, SecretError> {
|
||||
let mut rows = self
|
||||
.conn
|
||||
let conn = self.connect()?;
|
||||
let mut rows = conn
|
||||
.query(
|
||||
"SELECT 1 FROM secrets WHERE user_id = ?1 AND name = ?2",
|
||||
libsql::params![user_id, name],
|
||||
@@ -463,8 +472,8 @@ impl SecretsStore for LibSqlSecretsStore {
|
||||
}
|
||||
|
||||
async fn list(&self, user_id: &str) -> Result<Vec<SecretRef>, SecretError> {
|
||||
let mut rows = self
|
||||
.conn
|
||||
let conn = self.connect()?;
|
||||
let mut rows = conn
|
||||
.query(
|
||||
"SELECT name, provider FROM secrets WHERE user_id = ?1 ORDER BY name",
|
||||
libsql::params![user_id],
|
||||
@@ -487,8 +496,8 @@ impl SecretsStore for LibSqlSecretsStore {
|
||||
}
|
||||
|
||||
async fn delete(&self, user_id: &str, name: &str) -> Result<bool, SecretError> {
|
||||
let affected = self
|
||||
.conn
|
||||
let conn = self.connect()?;
|
||||
let affected = conn
|
||||
.execute(
|
||||
"DELETE FROM secrets WHERE user_id = ?1 AND name = ?2",
|
||||
libsql::params![user_id, name],
|
||||
@@ -501,18 +510,18 @@ impl SecretsStore for LibSqlSecretsStore {
|
||||
|
||||
async fn record_usage(&self, secret_id: Uuid) -> Result<(), SecretError> {
|
||||
let now = Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
|
||||
let conn = self.connect()?;
|
||||
|
||||
self.conn
|
||||
.execute(
|
||||
r#"
|
||||
conn.execute(
|
||||
r#"
|
||||
UPDATE secrets
|
||||
SET last_used_at = ?1, usage_count = usage_count + 1
|
||||
WHERE id = ?2
|
||||
"#,
|
||||
libsql::params![now.as_str(), secret_id.to_string()],
|
||||
)
|
||||
.await
|
||||
.map_err(|e| SecretError::Database(e.to_string()))?;
|
||||
libsql::params![now.as_str(), secret_id.to_string()],
|
||||
)
|
||||
.await
|
||||
.map_err(|e| SecretError::Database(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
+3
-4
@@ -803,11 +803,8 @@ impl SetupWizard {
|
||||
crypto: &Arc<SecretsCrypto>,
|
||||
) -> Result<Option<Arc<dyn SecretsStore>>, SetupError> {
|
||||
if let Some(ref backend) = self.db_backend {
|
||||
let conn = backend
|
||||
.connect()
|
||||
.map_err(|e| SetupError::Database(format!("Failed to create connection: {}", e)))?;
|
||||
let store: Arc<dyn SecretsStore> = Arc::new(crate::secrets::LibSqlSecretsStore::new(
|
||||
conn,
|
||||
backend.shared_db(),
|
||||
Arc::clone(crypto),
|
||||
));
|
||||
Ok(Some(store))
|
||||
@@ -1136,6 +1133,7 @@ impl Default for SetupWizard {
|
||||
}
|
||||
|
||||
/// Mask password in a database URL for display.
|
||||
#[cfg(feature = "postgres")]
|
||||
fn mask_password_in_url(url: &str) -> String {
|
||||
// URL format: scheme://user:password@host/database
|
||||
// Find "://" to locate start of credentials
|
||||
@@ -1326,6 +1324,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "postgres")]
|
||||
fn test_mask_password_in_url() {
|
||||
assert_eq!(
|
||||
mask_password_in_url("postgres://user:secret@localhost/db"),
|
||||
|
||||
+58
-38
@@ -567,15 +567,24 @@ fn row_to_tool(row: &tokio_postgres::Row) -> Result<StoredWasmTool, WasmStorageE
|
||||
// ==================== libSQL implementation ====================
|
||||
|
||||
/// libSQL/Turso implementation of WasmToolStore.
|
||||
///
|
||||
/// Holds an `Arc<Database>` handle and creates a fresh connection per operation,
|
||||
/// matching the connection-per-request pattern used by the main `LibSqlBackend`.
|
||||
#[cfg(feature = "libsql")]
|
||||
pub struct LibSqlWasmToolStore {
|
||||
conn: libsql::Connection,
|
||||
db: std::sync::Arc<libsql::Database>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "libsql")]
|
||||
impl LibSqlWasmToolStore {
|
||||
pub fn new(conn: libsql::Connection) -> Self {
|
||||
Self { conn }
|
||||
pub fn new(db: std::sync::Arc<libsql::Database>) -> Self {
|
||||
Self { db }
|
||||
}
|
||||
|
||||
fn connect(&self) -> Result<libsql::Connection, WasmStorageError> {
|
||||
self.db
|
||||
.connect()
|
||||
.map_err(|e| WasmStorageError::Database(format!("Connection failed: {}", e)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -589,9 +598,15 @@ impl WasmToolStore for LibSqlWasmToolStore {
|
||||
let schema_str = serde_json::to_string(¶ms.parameters_schema)
|
||||
.map_err(|e| WasmStorageError::InvalidData(e.to_string()))?;
|
||||
|
||||
self.conn
|
||||
.execute(
|
||||
r#"
|
||||
// Wrap INSERT + read-back in a transaction to prevent TOCTOU races
|
||||
let conn = self.connect()?;
|
||||
let tx = conn
|
||||
.transaction()
|
||||
.await
|
||||
.map_err(|e| WasmStorageError::Database(e.to_string()))?;
|
||||
|
||||
tx.execute(
|
||||
r#"
|
||||
INSERT INTO wasm_tools (
|
||||
id, user_id, name, version, description, wasm_binary, binary_hash,
|
||||
parameters_schema, source_url, trust_level, status, created_at, updated_at
|
||||
@@ -605,26 +620,25 @@ impl WasmToolStore for LibSqlWasmToolStore {
|
||||
source_url = excluded.source_url,
|
||||
updated_at = ?11
|
||||
"#,
|
||||
libsql::params![
|
||||
id.to_string(),
|
||||
params.user_id.as_str(),
|
||||
params.name.as_str(),
|
||||
params.version.as_str(),
|
||||
params.description.as_str(),
|
||||
libsql::Value::Blob(params.wasm_binary),
|
||||
libsql::Value::Blob(binary_hash),
|
||||
schema_str.as_str(),
|
||||
libsql_wasm_opt_text(params.source_url.as_deref()),
|
||||
params.trust_level.to_string(),
|
||||
now.as_str(),
|
||||
],
|
||||
)
|
||||
.await
|
||||
.map_err(|e| WasmStorageError::Database(e.to_string()))?;
|
||||
libsql::params![
|
||||
id.to_string(),
|
||||
params.user_id.as_str(),
|
||||
params.name.as_str(),
|
||||
params.version.as_str(),
|
||||
params.description.as_str(),
|
||||
libsql::Value::Blob(params.wasm_binary),
|
||||
libsql::Value::Blob(binary_hash),
|
||||
schema_str.as_str(),
|
||||
libsql_wasm_opt_text(params.source_url.as_deref()),
|
||||
params.trust_level.to_string(),
|
||||
now.as_str(),
|
||||
],
|
||||
)
|
||||
.await
|
||||
.map_err(|e| WasmStorageError::Database(e.to_string()))?;
|
||||
|
||||
// Read back the row
|
||||
let mut rows = self
|
||||
.conn
|
||||
// Read back the row within the same transaction
|
||||
let mut rows = tx
|
||||
.query(
|
||||
r#"
|
||||
SELECT id, user_id, name, version, description, parameters_schema,
|
||||
@@ -647,12 +661,18 @@ impl WasmToolStore for LibSqlWasmToolStore {
|
||||
WasmStorageError::Database("Insert succeeded but row not found".into())
|
||||
})?;
|
||||
|
||||
libsql_row_to_tool(&row)
|
||||
let tool = libsql_row_to_tool(&row)?;
|
||||
|
||||
tx.commit()
|
||||
.await
|
||||
.map_err(|e| WasmStorageError::Database(e.to_string()))?;
|
||||
|
||||
Ok(tool)
|
||||
}
|
||||
|
||||
async fn get(&self, user_id: &str, name: &str) -> Result<StoredWasmTool, WasmStorageError> {
|
||||
let mut rows = self
|
||||
.conn
|
||||
let conn = self.connect()?;
|
||||
let mut rows = conn
|
||||
.query(
|
||||
r#"
|
||||
SELECT id, user_id, name, version, description, parameters_schema,
|
||||
@@ -689,8 +709,8 @@ impl WasmToolStore for LibSqlWasmToolStore {
|
||||
user_id: &str,
|
||||
name: &str,
|
||||
) -> Result<StoredWasmToolWithBinary, WasmStorageError> {
|
||||
let mut rows = self
|
||||
.conn
|
||||
let conn = self.connect()?;
|
||||
let mut rows = conn
|
||||
.query(
|
||||
r#"
|
||||
SELECT id, user_id, name, version, description, wasm_binary, binary_hash,
|
||||
@@ -748,8 +768,8 @@ impl WasmToolStore for LibSqlWasmToolStore {
|
||||
&self,
|
||||
tool_id: Uuid,
|
||||
) -> Result<Option<StoredCapabilities>, WasmStorageError> {
|
||||
let mut rows = self
|
||||
.conn
|
||||
let conn = self.connect()?;
|
||||
let mut rows = conn
|
||||
.query(
|
||||
r#"
|
||||
SELECT id, wasm_tool_id, http_allowlist, allowed_secrets, tool_aliases,
|
||||
@@ -818,8 +838,8 @@ impl WasmToolStore for LibSqlWasmToolStore {
|
||||
|
||||
async fn list(&self, user_id: &str) -> Result<Vec<StoredWasmTool>, WasmStorageError> {
|
||||
// SQLite doesn't have DISTINCT ON, so we use a subquery to get latest version per name
|
||||
let mut rows = self
|
||||
.conn
|
||||
let conn = self.connect()?;
|
||||
let mut rows = conn
|
||||
.query(
|
||||
r#"
|
||||
SELECT id, user_id, name, version, description, parameters_schema,
|
||||
@@ -857,9 +877,9 @@ impl WasmToolStore for LibSqlWasmToolStore {
|
||||
status: ToolStatus,
|
||||
) -> Result<(), WasmStorageError> {
|
||||
let now = Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
|
||||
let conn = self.connect()?;
|
||||
|
||||
let result = self
|
||||
.conn
|
||||
let result = conn
|
||||
.execute(
|
||||
"UPDATE wasm_tools SET status = ?1, updated_at = ?2 WHERE user_id = ?3 AND name = ?4",
|
||||
libsql::params![status.to_string(), now.as_str(), user_id, name],
|
||||
@@ -875,8 +895,8 @@ impl WasmToolStore for LibSqlWasmToolStore {
|
||||
}
|
||||
|
||||
async fn delete(&self, user_id: &str, name: &str) -> Result<bool, WasmStorageError> {
|
||||
let result = self
|
||||
.conn
|
||||
let conn = self.connect()?;
|
||||
let result = conn
|
||||
.execute(
|
||||
"DELETE FROM wasm_tools WHERE user_id = ?1 AND name = ?2",
|
||||
libsql::params![user_id, name],
|
||||
|
||||
Reference in New Issue
Block a user