修复+重构: 全库走查真bug+架构+P1/P2 后端 crate
- df-nodes: schema required 对齐 + docker POSIX 注入防御 + HumanNode timeout 1800 + parse_review_json(verdict规范/score clamp/正则兜底) - df-mcp: update 实体校验(防跨实体 B-260801-01) - df-storage: keyring 迁移失败达阈值清除明文 - df-ai: router estimated_context+tier tiebreak+DataReadOnly 兜底 + sanitize step4 显式不制造 orphan - df-ideas: adversarial tier:None 对齐
This commit is contained in:
+270
-6
@@ -198,6 +198,57 @@ fn arg_int_or(args: &Value, key: &str, default: i32) -> i32 {
|
||||
.unwrap_or(default)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 跨实体校验(防 B-260801-01:跨实体误操作)
|
||||
// ============================================================
|
||||
//
|
||||
// 各实体表(projects / tasks / ideas)独立存储,Repo::get_by_id 只查本表。
|
||||
// 当 id 实属另一实体(如把 idea id 传给 update_task),本表查询返回 None,
|
||||
// 旧实现一律报「任务/项目/想法不存在」,错误信息具有误导性,且对 LLM/客户端
|
||||
// 跨实体误操作无防护(误以为 id 拼错重试,实际是实体类型搞错)。
|
||||
//
|
||||
// 此函数在「本表未命中」时跨另两张表探测 id 归属,返回命中的实体名
|
||||
// (Some("idea") / Some("task") / Some("project")),调用方据此报更精确错误:
|
||||
// 「id 属于 idea,不能用 update_task 修改」。三表都未命中 → None(真不存在)。
|
||||
//
|
||||
// 仅在错误路径(本表 None)执行,正常路径零开销。
|
||||
//
|
||||
// 返回值命名约定:中文实体名(对齐 handler 中文错误信息风格),与 handler 名一致。
|
||||
|
||||
/// 跨实体探测:id 在另两张表中的归属(None=都不在)。
|
||||
/// `excluding` 是调用方实体名(本表已查过,跳过避免重复查)。
|
||||
async fn detect_entity_owner(db: &Arc<Database>, id: &str, excluding: &str) -> Option<&'static str> {
|
||||
// 顺序按调用方常见误操作倾向排列(task ↔ idea 互混最常见,project 较少跨)。
|
||||
// 三个候选用 if 链(非循环)以静态分发各 Repo,避免 dyn。
|
||||
if excluding != "task" {
|
||||
if let Ok(Some(_)) = TaskRepo::new(db).get_by_id(id).await {
|
||||
return Some("task");
|
||||
}
|
||||
}
|
||||
if excluding != "idea" {
|
||||
if let Ok(Some(_)) = IdeaRepo::new(db).get_by_id(id).await {
|
||||
return Some("idea");
|
||||
}
|
||||
}
|
||||
if excluding != "project" {
|
||||
if let Ok(Some(_)) = ProjectRepo::new(db).get_by_id(id).await {
|
||||
return Some("project");
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// 跨实体错误信息构造:`excluding` 是当前 handler 期望的实体名,
|
||||
/// `id` 是客户端传入的 id。若 id 属于其他实体,返回描述性错误串;否则 None。
|
||||
async fn cross_entity_err(db: &Arc<Database>, id: &str, excluding: &str) -> Option<String> {
|
||||
match detect_entity_owner(db, id, excluding).await {
|
||||
Some(actual) => Some(format!(
|
||||
"id「{id}」属于 {actual},不能用 update_{excluding} 修改(跨实体误操作)"
|
||||
)),
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// handler 实现 — 项目
|
||||
// ============================================================
|
||||
@@ -275,7 +326,13 @@ fn update_project(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult>
|
||||
// 先读现有保留 path/stack/idea_id,以及未传字段的回退源(部分更新语义)
|
||||
let existing = match repo.get_by_id(&id).await {
|
||||
Ok(Some(p)) => p,
|
||||
Ok(None) => return CallToolResult::error(format!("项目不存在: {id}")),
|
||||
Ok(None) => {
|
||||
// 跨实体校验(B-260801-01):id 可能属于 task/idea,给精确错误防误操作
|
||||
if let Some(msg) = cross_entity_err(&db, &id, "project").await {
|
||||
return CallToolResult::error(msg);
|
||||
}
|
||||
return CallToolResult::error(format!("项目不存在: {id}"));
|
||||
}
|
||||
Err(e) => return err_str(e),
|
||||
};
|
||||
// 部分更新:name/description/status 缺省回退 existing,避免空默认清空数据
|
||||
@@ -431,7 +488,13 @@ fn update_task(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
|
||||
let repo = TaskRepo::new(&db);
|
||||
let existing = match repo.get_by_id(&id).await {
|
||||
Ok(Some(t)) => t,
|
||||
Ok(None) => return CallToolResult::error(format!("任务不存在: {id}")),
|
||||
Ok(None) => {
|
||||
// 跨实体校验(B-260801-01):id 可能属于 project/idea,给精确错误防误操作
|
||||
if let Some(msg) = cross_entity_err(&db, &id, "task").await {
|
||||
return CallToolResult::error(msg);
|
||||
}
|
||||
return CallToolResult::error(format!("任务不存在: {id}"));
|
||||
}
|
||||
Err(e) => return err_str(e),
|
||||
};
|
||||
// 部分更新:project_id/title/description 缺省回退 existing,避免空默认清空数据
|
||||
@@ -568,7 +631,13 @@ fn update_idea(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
|
||||
let repo = IdeaRepo::new(&db);
|
||||
let existing = match repo.get_by_id(&id).await {
|
||||
Ok(Some(i)) => i,
|
||||
Ok(None) => return CallToolResult::error(format!("想法不存在: {id}")),
|
||||
Ok(None) => {
|
||||
// 跨实体校验(B-260801-01):id 可能属于 project/task,给精确错误防误操作
|
||||
if let Some(msg) = cross_entity_err(&db, &id, "idea").await {
|
||||
return CallToolResult::error(msg);
|
||||
}
|
||||
return CallToolResult::error(format!("想法不存在: {id}"));
|
||||
}
|
||||
Err(e) => return err_str(e),
|
||||
};
|
||||
// 部分更新:title/description 缺省回退 existing,避免空默认清空数据
|
||||
@@ -763,9 +832,9 @@ fn normalize_path(p: &str) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::protocol::ContentBlock;
|
||||
use df_storage::crud::IdeaRepo;
|
||||
use df_storage::models::IdeaRecord;
|
||||
use df_types::types::{IdeaStatus, new_id};
|
||||
use df_storage::crud::{IdeaRepo, ProjectRepo, TaskRepo};
|
||||
use df_storage::models::{IdeaRecord, ProjectRecord, TaskRecord};
|
||||
use df_types::types::{IdeaStatus, ProjectStatus, TaskStatus, new_id};
|
||||
|
||||
/// 构造内存 DB + Ctx
|
||||
async fn test_ctx() -> Ctx {
|
||||
@@ -808,6 +877,51 @@ mod tests {
|
||||
repo.insert(rec).await.unwrap()
|
||||
}
|
||||
|
||||
/// 插入一条项目,返回 id(跨实体校验测试用)
|
||||
async fn seed_project(ctx: &Ctx, name: &str) -> String {
|
||||
let repo = ProjectRepo::new(&ctx.db);
|
||||
let now = now_millis();
|
||||
let rec = ProjectRecord {
|
||||
id: new_id(),
|
||||
name: name.to_owned(),
|
||||
description: String::new(),
|
||||
status: ProjectStatus::Planning,
|
||||
idea_id: None,
|
||||
path: None,
|
||||
stack: None,
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
};
|
||||
repo.insert(rec).await.unwrap()
|
||||
}
|
||||
|
||||
/// 插入一条任务,返回 id(跨实体校验测试用)
|
||||
async fn seed_task(ctx: &Ctx, project_id: &str, title: &str) -> String {
|
||||
let repo = TaskRepo::new(&ctx.db);
|
||||
let now = now_millis();
|
||||
let rec = TaskRecord {
|
||||
id: new_id(),
|
||||
project_id: project_id.to_owned(),
|
||||
title: title.to_owned(),
|
||||
description: String::new(),
|
||||
status: TaskStatus::Todo,
|
||||
priority: 0,
|
||||
branch_name: None,
|
||||
assignee: None,
|
||||
workflow_def_id: None,
|
||||
base_branch: None,
|
||||
review_rounds: 0,
|
||||
output_json: None,
|
||||
idea_id: None,
|
||||
queue: "todo".to_string(),
|
||||
parent_id: None,
|
||||
content_json: None,
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
};
|
||||
repo.insert(rec).await.unwrap()
|
||||
}
|
||||
|
||||
/// 读当前 DB 中的 idea.scores(原始字符串)
|
||||
async fn db_scores(ctx: &Ctx, id: &str) -> Option<String> {
|
||||
IdeaRepo::new(&ctx.db)
|
||||
@@ -948,4 +1062,154 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 跨实体校验(B-260801-01):update_* 检测 id 属于其他实体时报精确错误 ──
|
||||
//
|
||||
// 各实体表独立,Repo::get_by_id 只查本表。当 id 实属另一实体时,
|
||||
// 旧实现只报「任务/项目/想法不存在」(误导),改后报「id 属于 X,不能用 update_Y 修改」。
|
||||
|
||||
/// update_task 传入 idea id → 报跨实体错误,不报「任务不存在」。
|
||||
#[tokio::test]
|
||||
async fn update_task_with_idea_id_reports_cross_entity() {
|
||||
let ctx = test_ctx().await;
|
||||
let idea_id = seed_idea(&ctx, "灵感A", "误传给 update_task").await;
|
||||
|
||||
let r = update_task(&ctx, json!({ "id": idea_id, "title": "x" })).await;
|
||||
assert_eq!(r.is_error, Some(true));
|
||||
let msg = text_of(&r);
|
||||
assert!(
|
||||
msg.contains("属于 idea") && msg.contains("update_task"),
|
||||
"应报跨实体错误,实际: {msg}"
|
||||
);
|
||||
// 不应回退到模糊的「任务不存在」
|
||||
assert!(!msg.contains("任务不存在"), "不应是模糊错误: {msg}");
|
||||
}
|
||||
|
||||
/// update_task 传入 project id → 报跨实体错误。
|
||||
#[tokio::test]
|
||||
async fn update_task_with_project_id_reports_cross_entity() {
|
||||
let ctx = test_ctx().await;
|
||||
let pid = seed_project(&ctx, "项目P").await;
|
||||
|
||||
let r = update_task(&ctx, json!({ "id": pid, "title": "x" })).await;
|
||||
assert_eq!(r.is_error, Some(true));
|
||||
let msg = text_of(&r);
|
||||
assert!(
|
||||
msg.contains("属于 project") && msg.contains("update_task"),
|
||||
"应报跨实体错误,实际: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
/// update_project 传入 idea id → 报跨实体错误。
|
||||
#[tokio::test]
|
||||
async fn update_project_with_idea_id_reports_cross_entity() {
|
||||
let ctx = test_ctx().await;
|
||||
let idea_id = seed_idea(&ctx, "灵感B", "误传给 update_project").await;
|
||||
|
||||
let r = update_project(&ctx, json!({ "id": idea_id, "name": "x" })).await;
|
||||
assert_eq!(r.is_error, Some(true));
|
||||
let msg = text_of(&r);
|
||||
assert!(
|
||||
msg.contains("属于 idea") && msg.contains("update_project"),
|
||||
"应报跨实体错误,实际: {msg}"
|
||||
);
|
||||
assert!(!msg.contains("项目不存在"), "不应是模糊错误: {msg}");
|
||||
}
|
||||
|
||||
/// update_project 传入 task id → 报跨实体错误。
|
||||
#[tokio::test]
|
||||
async fn update_project_with_task_id_reports_cross_entity() {
|
||||
let ctx = test_ctx().await;
|
||||
let pid = seed_project(&ctx, "项目宿主").await;
|
||||
let tid = seed_task(&ctx, &pid, "任务T").await;
|
||||
|
||||
let r = update_project(&ctx, json!({ "id": tid, "name": "x" })).await;
|
||||
assert_eq!(r.is_error, Some(true));
|
||||
let msg = text_of(&r);
|
||||
assert!(
|
||||
msg.contains("属于 task") && msg.contains("update_project"),
|
||||
"应报跨实体错误,实际: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
/// update_idea 传入 task id → 报跨实体错误。
|
||||
#[tokio::test]
|
||||
async fn update_idea_with_task_id_reports_cross_entity() {
|
||||
let ctx = test_ctx().await;
|
||||
let pid = seed_project(&ctx, "项目宿主").await;
|
||||
let tid = seed_task(&ctx, &pid, "任务T2").await;
|
||||
|
||||
let r = update_idea(&ctx, json!({ "id": tid, "title": "x" })).await;
|
||||
assert_eq!(r.is_error, Some(true));
|
||||
let msg = text_of(&r);
|
||||
assert!(
|
||||
msg.contains("属于 task") && msg.contains("update_idea"),
|
||||
"应报跨实体错误,实际: {msg}"
|
||||
);
|
||||
assert!(!msg.contains("想法不存在"), "不应是模糊错误: {msg}");
|
||||
}
|
||||
|
||||
/// update_idea 传入 project id → 报跨实体错误。
|
||||
#[tokio::test]
|
||||
async fn update_idea_with_project_id_reports_cross_entity() {
|
||||
let ctx = test_ctx().await;
|
||||
let pid = seed_project(&ctx, "项目P2").await;
|
||||
|
||||
let r = update_idea(&ctx, json!({ "id": pid, "title": "x" })).await;
|
||||
assert_eq!(r.is_error, Some(true));
|
||||
let msg = text_of(&r);
|
||||
assert!(
|
||||
msg.contains("属于 project") && msg.contains("update_idea"),
|
||||
"应报跨实体错误,实际: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
/// 三表都不存在的真随机 id → 仍报原「不存在」错误(跨实体校验不应改变此行为)。
|
||||
#[tokio::test]
|
||||
async fn update_task_with_unknown_id_still_reports_not_found() {
|
||||
let ctx = test_ctx().await;
|
||||
let r = update_task(&ctx, json!({ "id": "ghost-id-12345", "title": "x" })).await;
|
||||
assert_eq!(r.is_error, Some(true));
|
||||
let msg = text_of(&r);
|
||||
assert!(
|
||||
msg.contains("任务不存在"),
|
||||
"三表都无此 id 应回退到原「不存在」错误,实际: {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
/// 正常路径:update_task 传入真实 task id → 成功(校验不应破坏正常路径)。
|
||||
#[tokio::test]
|
||||
async fn update_task_with_real_task_id_succeeds() {
|
||||
let ctx = test_ctx().await;
|
||||
let pid = seed_project(&ctx, "项目宿主").await;
|
||||
let tid = seed_task(&ctx, &pid, "原标题").await;
|
||||
|
||||
let r = update_task(&ctx, json!({ "id": tid, "title": "新标题" })).await;
|
||||
assert!(r.is_error.is_none(), "正常路径不应报错: {:?}", text_of(&r));
|
||||
let v = json_of(&r);
|
||||
assert_eq!(v["task"]["title"], "新标题");
|
||||
}
|
||||
|
||||
/// 跨实体探测纯逻辑:三实体互查正确性。
|
||||
#[tokio::test]
|
||||
async fn detect_entity_owner_returns_correct_entity() {
|
||||
let ctx = test_ctx().await;
|
||||
let pid = seed_project(&ctx, "项目").await;
|
||||
let tid = seed_task(&ctx, &pid, "任务").await;
|
||||
let iid = seed_idea(&ctx, "灵感", "x").await;
|
||||
|
||||
// 各 id 跨表探测应返回其真实所属实体(注意:不包括 excluding 自身)
|
||||
assert_eq!(detect_entity_owner(&ctx.db, &tid, "task").await, None, "task 自身被排除");
|
||||
assert_eq!(detect_entity_owner(&ctx.db, &tid, "project").await, Some("task"));
|
||||
assert_eq!(detect_entity_owner(&ctx.db, &tid, "idea").await, Some("task"));
|
||||
|
||||
assert_eq!(detect_entity_owner(&ctx.db, &iid, "idea").await, None, "idea 自身被排除");
|
||||
assert_eq!(detect_entity_owner(&ctx.db, &iid, "task").await, Some("idea"));
|
||||
|
||||
assert_eq!(detect_entity_owner(&ctx.db, &pid, "project").await, None, "project 自身被排除");
|
||||
assert_eq!(detect_entity_owner(&ctx.db, &pid, "task").await, Some("project"));
|
||||
|
||||
// 三表都没有 → None
|
||||
assert_eq!(detect_entity_owner(&ctx.db, "ghost", "task").await, None);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user