//! 通用键值设置 — 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" => &[ // IDEA-FIX-02: id\created_at 不列入 — 主键与创建时间不可通过通用 update_field 改写 // (对标 tasks 白名单 B-260616-16 同款防护,防篡改主键/伪造创建时间) "title", "description", "status", "priority", "score", "tags", "source", "promoted_to", "ai_analysis", "scores", "related_ids", "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: 预留字段,阶段4 Git/workflow_defs 集成前无写入路径。 // 当前推进链用硬编码三模板(task_workflow_templates.rs,不建 workflow_defs 表, // tasks.workflow_def_id 留 None),无任何代码写这两列。白名单列入仅为阶段4 预留 + // 允许手动/未来填充,勿判死代码删除。base_branch 同理(code kind 闸门接 git 前预留)。 "workflow_def_id", "base_branch", // output_json:ai_execute 写产出 / ai_self_review 读产出自审 / human_review 展示对象 // (决策 a:task 中心,产出跟 task 走)。非状态机收口字段,合法可写。 "output_json", // idea_id(F-260619-01 任务关联灵感,1对1 单向):任务可关联/解关联一条灵感, // 非状态机收口字段,合法可写(idea_id 存在性由外键约束 + 上层校验兜底)。 "idea_id", // 知识图谱 Phase 1 V29 三列(对标设计 §2.1):非状态机收口字段,合法可写。 // - queue:管理维度池,走 move_task_queue IPC(设计 §五),IPC 层校验白名单 + 一致性约束。 // - parent_id:父子纵向关联,1 级嵌套铁律由 IPC 层(create_task)校验不进白名单。 // - content_json:结构化需求规格,走未来 update_content IPC(设计 §五)。 // 注:status 仍不列入(D-260616-04 收口不变),父聚合/move_task_queue 联动 status // 走专用方法 set_status_for_aggregation,不经通用 update_field。 "queue", "parent_id", "content_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", ], // api_key 不列入白名单:密钥写入应只走 update_full/insert + keyring 配套, // 禁止 update_field 旁路绕过 keyring(防明文写回 DB)。 "ai_providers" => &[ "id", "name", "provider_type", "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", "message_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", ], // 知识图谱 Phase 3 V31(对标设计 §2.3):project_services 表有 updated_at,走 impl_repo! 宏 // 标准 CRUD(insert/get_by_id/list_all/query/update_field/update_full/delete),故登记白名单。 // id/created_at 不列入:主键与创建时间不可经通用 update_field 改写(对标 tasks/projects 防护)。 // service_type 不列入白名单(类比 status 收口思路):service_type 是类型枚举,变更应走整行 // update_full(过 service_type 应用层校验 + 凭证审查),而非 update_field 旁路绕过校验。 "project_services" => &[ "project_id", "name", "endpoint", "config_json", "environment", "remark", "updated_at", ], _ => 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, } }