//! 通用键值设置 — app_settings 表(KV,不适合 impl_repo! 宏)+ 列白名单(防 SQL 注入) use std::sync::Arc; use rusqlite::{params, Connection, OptionalExtension}; use tokio::sync::Mutex; use df_types::error::{Error, Result}; use crate::db::Database; use super::{now_millis_str, storage_err}; // ============================================================ // 通用键值设置 — app_settings 表 // ============================================================ /// 通用应用设置 KV Repo(表 `app_settings`) /// /// 前端 localStorage 迁移目标:存主题/语言/AI 偏好/连接配置等,`value` 为 JSON 字符串。 /// KV 无固定 schema 记录结构,故不走 `impl_repo!` 宏,手写更直白。 pub struct SettingsRepo { conn: Arc>, } impl SettingsRepo { pub fn new(db: &Database) -> Self { Self { conn: db.conn() } } /// 取单个 key 的值(JSON 字符串),不存在返回 None pub async fn get(&self, key: &str) -> Result> { let conn = self.conn.clone(); let key = key.to_owned(); tokio::task::spawn_blocking(move || { let guard = conn.blocking_lock(); let v = guard .query_row( "SELECT value FROM app_settings WHERE key = ?1", params![key], |row| row.get::<_, String>(0), ) .optional() .map_err(storage_err)?; Ok(v) }) .await .map_err(storage_err)? } /// 写 key/value(`INSERT OR REPLACE`),刷新 updated_at pub async fn set(&self, key: &str, value: &str) -> Result { let conn = self.conn.clone(); let key = key.to_owned(); let value = value.to_owned(); let now = now_millis_str(); tokio::task::spawn_blocking(move || { let guard = conn.blocking_lock(); let affected = guard .execute( "INSERT OR REPLACE INTO app_settings (key, value, updated_at) VALUES (?1, ?2, ?3)", params![key, value, now], ) .map_err(storage_err)?; Ok(affected > 0) }) .await .map_err(storage_err)? } /// 取全部 key/value pub async fn get_all(&self) -> Result> { let conn = self.conn.clone(); tokio::task::spawn_blocking(move || { let guard = conn.blocking_lock(); let mut stmt = guard .prepare("SELECT key, value FROM app_settings") .map_err(storage_err)?; let rows = stmt .query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))) .map_err(storage_err)?; let mut results = Vec::new(); for r in rows { results.push(r.map_err(storage_err)?); } Ok(results) }) .await .map_err(storage_err)? } /// 删除 key,返回是否实际删除 pub async fn delete(&self, key: &str) -> Result { let conn = self.conn.clone(); let key = key.to_owned(); tokio::task::spawn_blocking(move || { let guard = conn.blocking_lock(); let affected = guard .execute("DELETE FROM app_settings WHERE key = ?1", params![key]) .map_err(storage_err)?; Ok(affected > 0) }) .await .map_err(storage_err)? } } // ============================================================ // 列名白名单 — 防止 SQL 注入 // ============================================================ /// 按表返回允许用于 query / update_field 的列名。 /// /// 白名单两重作用:① 防注入(列名参数化前校验);② 按表隔离(update_task 误传 /// projects 的 "name" 会在校验阶段拒绝,而非靠 SQLite "no such column" 兜底报错)。 /// 专用更新路径的列不列入:knowledges.embedding(set_embedding)、projects.deleted_at /// (soft_delete/restore)、tasks.deleted_at(soft_delete/restore)。未登记的表返回 None /// → 放行(仅靠参数化防注入,向后兼容)。 pub fn allowed_columns_for(table: &str) -> Option<&'static [&'static str]> { Some(match table { "ideas" => &[ "id", "title", "description", "status", "priority", "score", "tags", "source", "promoted_to", "ai_analysis", "scores", "created_at", "updated_at", ], "projects" => &[ "id", "name", "description", "status", "idea_id", "path", "stack", "created_at", "updated_at", ], "tasks" => &[ // id/created_at 不列入:主键与创建时间不可通过通用 update_field 改写 // (防篡改主键/伪造创建时间/跨项目移动)。 // status 不列入(D-260616-04 status 收口):所有 status 改动走 // advance_status_atomic(CAS SQL `WHERE id AND status=expected`,唯一 status 写入路径), // 不经通用 update_field。防 update_task IPC / AI 工具旁路改 status 绕过状态机 // (can_transition 校验)与 review_rounds 累加。后人勿把 status 补回白名单。 // review_rounds 不列入:它仅 advance_status_atomic 退回转换 // (in_review→in_progress / testing→in_review)时原子 +1(收口:仅该专用路径可改, // update_task/update_field 白名单均不含)。后人勿把 review_rounds 补进白名单, // 否则破坏「review_rounds 唯一写入路径」收口、引入旁路写导致计数错乱。 "project_id", "title", "description", "priority", "branch_name", "assignee", "workflow_def_id", "base_branch", // output_json:ai_execute 写产出 / ai_self_review 读产出自审 / human_review 展示对象 // (决策 a:task 中心,产出跟 task 走)。非状态机收口字段,合法可写。 "output_json", "updated_at", // TODO(B-260616-16): project_id 跨表存在性校验待 commands/task.rs 层补。 // 通用 CRUD 层(db repo)只懂表/列语义,不持有跨表业务约束(查 projects 表存在性)。 // project_id 当前可在白名单内改写,合法目标存在性由上层命令层校验。 ], "releases" => &[ "id", "project_id", "version", "status", "task_ids", "changelog", "created_at", "released_at", ], "branches" => &[ "id", "project_id", "task_id", "name", "base", "status", "created_at", "updated_at", "merged_at", ], "workflow_executions" => &[ "id", "name", "dag_json", "status", "triggered_by", "project_id", "task_id", "created_at", "completed_at", ], "node_executions" => &[ "id", "workflow_id", "node_id", "node_type", "status", "input_json", "output_json", "error_message", "started_at", "completed_at", ], "ai_providers" => &[ "id", "name", "provider_type", "api_key", "base_url", "default_model", "models", "is_default", "config", "created_at", "updated_at", ], "ai_conversations" => &[ "id", "title", "messages", "provider_id", "model", "models", "archived", "prompt_tokens", "completion_tokens", "created_at", "updated_at", ], "ai_tool_executions" => &[ "id", "conversation_id", "tool_call_id", "tool_name", "arguments", "result", "status", "risk_level", "requested_at", "executed_at", "decided_by", ], "knowledges" => &[ "id", "kind", "title", "content", "tags", "status", "confidence", "reuse_count", "verified", "source_project", "source_ref", "reasoning", "created_at", "updated_at", ], "knowledge_events" => &[ "id", "knowledge_id", "event_type", "source_ref", "context_json", "timestamp", ], _ => return None, }) } pub(crate) fn validate_column_name(field: &str, table: &str) -> Result<()> { match allowed_columns_for(table) { Some(cols) if cols.contains(&field) => Ok(()), Some(_) => Err(Error::Storage(format!("表 {} 不允许的字段名: {}", table, field))), // 未登记表保守拒绝(FR-S6: 原放行 Ok,若未来未登记表走通用查询路径,列名直进字符串拼接即 SQL 注入;与 is_allowed_column 的 None=>false 对齐) None => Err(Error::Storage(format!("表 {} 未登记列白名单,拒绝防注入", table))), } } /// 是否允许更新某表的某列(按表隔离白名单)。未登记表返回 false(保守拒绝,防误传)。 /// /// 供 AI 工具层等外部调用方复用 CRUD 白名单,避免双份字段列表不同步。 pub fn is_allowed_column(table: &str, field: &str) -> bool { match allowed_columns_for(table) { Some(cols) => cols.contains(&field), None => false, } }