修复: workspace_root 编译期常量消除+data_dir 运行期+首次授权引导

- 删除 production 中全部 env!("CARGO_MANIFEST_DIR")引用
- workspace_root() 替换为运行期 data_dir(Tauri app_data_dir)
- 相对路径锚定改为 AllowedDirs.first_persistent_dir(),无授权时引导绑定项目
- .trash 迁到 data_dir/.trash,含自动迁移
- authz_debug → temp_dir,workspace_drive → current_dir
- run_command 默认 working_dir 改为空串
- G1 目标刷新改为每次消息覆盖(含 ai_chat_edit 路径)
- #6 评分关键词拆到独立文件 scoring_keywords.rs
- #7 promote 补偿删除 purge_with_descendants → soft_delete
- #8 关联双向同步: 后端事务 sync_related_ids + IPC + 前端全链路
- 设置面板新增数据目录显示
This commit is contained in:
2026-06-27 21:32:12 +08:00
parent a6d692270f
commit 9c3f27f4ca
26 changed files with 743 additions and 104 deletions

View File

@@ -286,15 +286,6 @@ fn compute_file_hash(meta: &std::fs::Metadata) -> String {
format!("{}_{}", modified.unwrap_or(0), meta.len())
}
/// workspace 根目录(项目根 = src-tauri 上两级,编译期固定)
fn workspace_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.and_then(|p| p.parent())
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."))
}
/// 阶段4(容错/恢复,开关 `df-ai-approval-retry`):跨盘/跨卷文件移动统一降级 helper。
///
/// 背景:Windows 跨盘符(C→E)或跨卷时 `tokio::fs::rename` 报 `os error 17`
@@ -339,19 +330,15 @@ async fn rename_or_cross_volume_copy(
}
}
/// 解析文件工具路径:相对路径锚定 workspace_root,禁止越出项目目录
/// 解析文件工具路径:相对路径锚定已授权目录的首个 persistent 目录,禁止越出项目目录
///
/// 双层校验:
/// 1. 词法层 is_authorized——对不存在路径(write_file 新建文件)兜底防越界
/// 2. canonicalize 层——对存在路径解析符号链接,防 workspace 内 symlink 指向外部的逃逸
/// 2. canonicalize 层——对存在路径解析符号链接,防授权目录内 symlink 指向外部的逃逸
/// 仅校验,返回词法 resolved(不含 \\?\ 前缀),保证 read_file 返回的 path 对前端友好
///
/// F-260619-03 Phase A: 引入 AllowedDirs 白名单(动态多目录授权)。
/// - `resolve_workspace_path_with_allowed(path, &AllowedDirs)`: 文件工具闭包用,
/// 走完整白名单校验(workspace_root 始终授权 + persistent 任一命中)。
/// - `resolve_workspace_path_pub(path)`: trust_key_for 计算 TrustKey 目录归一化用,
/// 无白名单上下文,用 default_with_root(仅 workspace_root),零回归。
/// 闭包捕获 `Arc<RwLock<AllowedDirs>>` 后 read lock 取快照传入本函数。
/// 相对路径锚定到 AllowedDirs 首个 persistent 目录(用户授权的第一个项目/目录),
/// 无任何授权时拒绝并用引导信息提示用户绑定项目。绝对路径不受影响(直接走白名单校验)。
fn resolve_workspace_path_with_allowed(
path: &str,
allowed: &AllowedDirs,
@@ -363,31 +350,31 @@ fn resolve_workspace_path_with_allowed(
/// 归一化路径(去 .. / symlink 逃逸。pub(crate) wrapper 转调私有 impl保持原私有函数
/// 可见性边界(不暴露给 crate 外,但允许同 crate ai 模块 mod.rs 调用)。
///
/// F-260619-03 Phase A: trust_key 计算无需白名单上下文(只是目录归一化作 HashSet key),
/// 用 default_with_root(仅 workspace_root),零回归。白名单校验由文件工具 handler 在
/// 实际执行时走 resolve_workspace_path_with_allowed,信任键计算的越界失败不影响
/// (trust_key_for 已对 Err 回退 workspace_root_str 保守默认)。
/// 传空白名单(default):相对路径会因无授权目录而 Err(trust_key_for 已对 Err
/// 回退 workspace_root_str 保守默认)。白名单校验由文件工具 handler 在实际执行时
/// 走 resolve_workspace_path_with_allowed,信任键计算的越界失败不影响
pub(crate) fn resolve_workspace_path_pub(path: &str) -> anyhow::Result<PathBuf> {
resolve_workspace_path_impl(path, &AllowedDirs::default_with_root())
resolve_workspace_path_impl(path, &AllowedDirs::default())
}
fn resolve_workspace_path_impl(path: &str, allowed: &AllowedDirs) -> anyhow::Result<PathBuf> {
validate_path(path)?;
let root = workspace_root();
let resolved = if Path::new(path).is_absolute() {
PathBuf::from(path)
} else {
// 相对路径锚定到首个持久授权目录(用户绑定的第一个项目)。
// 无授权目录时给出明确引导,避免指向编译期常量。
let root = allowed.first_persistent_dir()
.ok_or_else(|| anyhow::anyhow!(
"请先绑定项目目录,或使用绝对路径 (如 E:/your-project/src/main.rs)"
))?;
root.join(path)
};
// 词法层:防明显越界(不存在路径的兜底)。
// F-260619-03 Phase A: 单根 starts_with 升级为 AllowedDirs 白名单多目录校验
// (workspace_root 始终在白名单 → 旧单根路径零回归;新授权目录命中放行)。
if !allowed.is_authorized(&resolved) {
anyhow::bail!("路径不在授权目录内: {}", path);
}
// canonicalize 层:存在路径解析 symlink,防经符号链接逃逸出授权目录。
// 用 canonicalize 后的真实路径再做一次 is_authorized 校验(防 workspace 内 symlink
// 指向白名单外目录的逃逸)。default_with_root 时等价旧 root.canonicalize 比对。
if resolved.exists() {
let canon_resolved = resolved.canonicalize()?;
if !allowed.is_authorized(&canon_resolved) {
@@ -449,10 +436,11 @@ async fn bind_dir_to_project(
pub fn build_ai_tool_registry(
db: &Arc<Database>,
allowed_dirs: &Arc<RwLock<AllowedDirs>>,
data_dir: PathBuf,
) -> AiToolRegistry {
let mut registry = AiToolRegistry::new();
register_data_tools(&mut registry, db);
register_file_tools(&mut registry, allowed_dirs);
register_file_tools(&mut registry, allowed_dirs, data_dir);
register_http_tools(&mut registry);
registry
}
@@ -1412,6 +1400,7 @@ fn register_trash_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
fn register_file_tools(
registry: &mut AiToolRegistry,
allowed_dirs: &Arc<RwLock<AllowedDirs>>,
data_dir: PathBuf,
) {
// F-260619-03 Phase A: Arc clone 进各文件工具闭包(handler 执行时 read lock 取白名单快照)。
// run_command 不走白名单(High risk 靠人工审批兜底,放开目录让 AI 在用户任意项目目录闭环),
@@ -1967,8 +1956,9 @@ fn register_file_tools(
("permanent", "boolean", false),
]),
RiskLevel::High,
{ let allowed_dirs = allowed_dirs.clone(); Box::new(move |args: serde_json::Value| {
{ let allowed_dirs = allowed_dirs.clone(); let data_dir = data_dir.clone(); Box::new(move |args: serde_json::Value| {
let allowed_dirs = allowed_dirs.clone();
let data_dir = data_dir.clone();
Box::pin(async move {
// validate_path 前置(workspace 内)——resolve_workspace_path_with_allowed 含 validate_path + symlink 防逃逸
let snap = allowed_dirs.read().await.clone();
@@ -2004,15 +1994,15 @@ fn register_file_tools(
}))
} else {
// 软删除:移到 .trash/<uuid>-<filename>,可恢复(对齐 list_trash 机制)
// .trash 放 workspace_root 下,validate_path 已确认 path 在 workspace
let trash_dir = workspace_root().join(".trash");
// .trash 放 app_data_dir 下,不依赖编译期 workspace_root
let trash_dir = data_dir.join(".trash");
tokio::fs::create_dir_all(&trash_dir).await
.map_err(|e| anyhow::anyhow!("创建 .trash 目录失败: {}", e))?;
let backup_name = format!("{}-{}", new_id(), file_name);
let backup_path = trash_dir.join(&backup_name);
let backup_path_str = backup_path.to_string_lossy().to_string();
// 软删除跨盘降级(F-260621):同盘 rename 原子;跨盘(Windows EXDEV os error 17,
// 如 C盘授权路径 → E盘 workspace_root/.trash)rename 失败,降级 copy + remove(非原子,
// 如 C盘授权路径 → data_dir/.trash)rename 失败,降级 copy + remove(非原子,
// 失败回滚删 backup 保源完整)。
// 阶段4:跨盘降级抽统一 helper rename_or_cross_volume_copy(对齐 rename_file 跨卷处理),
// 消除两处 copy+remove 字面量重复。原直接 rename bail 致 delete_file 跨盘场景全失败
@@ -2325,16 +2315,14 @@ fn register_file_tools(
Box::new(|args: serde_json::Value| Box::pin(async move {
let command = args["command"].as_str()
.ok_or_else(|| anyhow::anyhow!("缺少 command 参数"))?;
// working_dir 默认 workspace_root()(与 write_file 锚定一致:AI 写代码→同目录跑命令,闭环)
// 走 validate_path 黑名单(.. + 敏感系统目录)作基础防线;不走 resolve_workspace_path 越界校验——
// run_command 是 High risk 靠人工审批兜底(用户在审批卡看清 command+working_dir),
// 放开目录才能让 AI 在用户任意项目目录形成「写→跑→看→改」真闭环。
// working_dir 默认空(由审批弹窗让用户填写),不走 workspace_root 编译期常量
// run_command 是 High risk 靠人工审批兜底,不捕获 allowed_dirs。
let working_dir = match args.get("working_dir").and_then(|v| v.as_str()) {
Some(d) => {
validate_path(d)?;
d.to_string()
}
None => workspace_root().to_string_lossy().to_string(),
None => String::new(),
};
// timeout 默认 60s:防 hang(交互式命令/死循环/大构建),LLM 可通过 args timeout_secs 覆盖
// clamp 封顶 MAX:防 LLM 传超大 timeout_secs 冻结会话(需更长命令应拆分而非无限等)