Files
DevFlow/crates/df-storage/src/crud/settings.rs
T
lxy ff3f153d45 修复: 安全加固+DRY 收敛+文档同步+测试补齐
安全:
- ScriptNode 默认黑名单兜底(rm/del/format/shutdown/mkfs/dd)
- bind_directory 分段 .. 检测替代 contains 子串(对齐 tool_registry)
- ai_providers 白名单移除 api_key(防 update_field 旁路写明文)

DRY:
- useAiEvents 抽 cleanupTerminatedConversation 统一三分支收尾
- 新增 useStoreAction 工具,4 个 store 替换 38 处 try/catch 样板

文档:
- df-core → df-types 批量替换(ARCHITECTURE/PROGRESS/SQLite-CRUD)
- INDEX 补齐 9 漏列文档(单对话并行多轮/跑题试验/工程系统设计等)
- Agent架构说明 死链修复(../构想审查/)
- AI对话引擎工具清单改为数量+按风险分组(不再用固定数字)
- ARCH 状态标签 设计阶段 → Phase 2 验证

测试:
- df-relay 新增 registry_test: ConnRegistry 路由 + RelayState + 16 项单测
2026-06-29 21:57:07 +08:00

235 lines
11 KiB
Rust

//! 通用键值设置 — 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<Mutex<Connection>>,
}
impl SettingsRepo {
pub fn new(db: &Database) -> Self {
Self { conn: db.conn() }
}
/// 取单个 key 的值(JSON 字符串),不存在返回 None
pub async fn get(&self, key: &str) -> Result<Option<String>> {
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<bool> {
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<Vec<(String, String)>> {
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<bool> {
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,
}
}