修复: 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:
@@ -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 冻结会话(需更长命令应拆分而非无限等)
|
||||
|
||||
Reference in New Issue
Block a user