会话意图识别层(intent.rs / df-ai crate):
- Intent 枚举 11 态 + recognize 规则识别(关键词+权重+优先级)+ tool_subset_for 工具子集映射
- 模态接口预留 None,独立模块待接入 loop,36 单测
文件访问权限模型 Phase B+C(F-260619-03):
- Phase B 会话临时白名单 + 循环挂起 + DirAuthDialog 弹窗(仅本次/未来都允许/拒绝)
- Phase C 系统目录黑名单(Win System32/Program Files;Unix /etc /usr...)分段匹配 + 写操作约束
- 已审 CR-260619-09 ✅ PASS
904 lines
44 KiB
Rust
904 lines
44 KiB
Rust
//! 应用全局状态 — 数据库、Repo、事件总线、节点注册表、AI 会话
|
||
|
||
use std::collections::{HashMap, HashSet};
|
||
use std::path::{Path, PathBuf};
|
||
use std::sync::Arc;
|
||
use std::sync::atomic::AtomicUsize;
|
||
|
||
use anyhow::Result;
|
||
use serde::{Deserialize, Serialize};
|
||
use tokio::sync::{Mutex, RwLock, Semaphore};
|
||
|
||
use df_ai::ai_tools::AiToolRegistry;
|
||
use df_storage::crud::{
|
||
AiConversationRepo, AiProviderRepo, AiToolExecutionRepo, IdeaRepo, KnowledgeEventsRepo,
|
||
KnowledgeRepo, NodeExecutionRepo, ProjectRepo, ReleaseRepo, SettingsRepo, TaskRepo,
|
||
WorkflowRepo,
|
||
};
|
||
use df_storage::db::Database;
|
||
use df_workflow::eventbus::EventBus;
|
||
use df_workflow::registry::NodeRegistry;
|
||
use df_workflow::state::StateMachine;
|
||
|
||
use crate::commands::ai::AiSession;
|
||
|
||
// ============================================================
|
||
// 知识库配置(提取 + 注入)
|
||
// ============================================================
|
||
|
||
/// AI 提炼触发方式
|
||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||
#[serde(rename_all = "snake_case")]
|
||
pub enum ExtractTrigger {
|
||
/// 对话正常完成时(默认)
|
||
OnComplete,
|
||
/// 对话闲置 N 秒后
|
||
OnIdle,
|
||
/// 仅手动按钮触发
|
||
ManualOnly,
|
||
}
|
||
|
||
/// 知识库行为配置(存 AppState 内存,前后端通过 IPC 读写)
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct KnowledgeConfig {
|
||
/// 提炼总开关,默认 true
|
||
pub auto_extract: bool,
|
||
/// 提炼触发方式,默认 OnComplete
|
||
pub trigger_mode: ExtractTrigger,
|
||
/// 最少消息数守卫(防闲聊噪音),默认 4
|
||
pub min_messages: u32,
|
||
/// 闲置触发超时(ms),默认 30000
|
||
pub idle_timeout_ms: u64,
|
||
/// 聊天时自动注入相关知识开关,默认 true
|
||
pub auto_inject: bool,
|
||
/// 语义检索(向量)总开关,默认 false——关闭时纯 LIKE 零外部依赖
|
||
#[serde(default)]
|
||
pub vector_enabled: bool,
|
||
/// embedding 用的 provider id(仅 openai_compat 类型,Anthropic 无 embed API)
|
||
#[serde(default)]
|
||
pub embedding_provider_id: Option<String>,
|
||
/// embedding 模型名(如 embedding-3 / text-embedding-3-small)
|
||
#[serde(default)]
|
||
pub embedding_model: Option<String>,
|
||
}
|
||
|
||
impl Default for KnowledgeConfig {
|
||
fn default() -> Self {
|
||
Self {
|
||
auto_extract: true,
|
||
trigger_mode: ExtractTrigger::OnComplete,
|
||
min_messages: 4,
|
||
idle_timeout_ms: 30_000,
|
||
auto_inject: true,
|
||
vector_enabled: false,
|
||
embedding_provider_id: None,
|
||
embedding_model: None,
|
||
}
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// LLM 调用并发控制(双层 Semaphore + 可选 per-provider 层)
|
||
// ============================================================
|
||
|
||
/// LLM 调用并发控制 — 全局 LLM 调用限流 global + 单对话内 per_conv 双层 Semaphore + 可选 per-provider 层
|
||
///
|
||
/// 限流对象:所有真实 LLM 调用(主循环 stream_llm / 标题生成 / 知识提炼)。
|
||
/// 不限流本地工具执行(tools.execute)——本地操作无外部成本、不受 RPM 约束。
|
||
///
|
||
/// ## global(permits 默认 3,「LLM 调用并发限流」原义)
|
||
/// F-09 batch5 曾把 global 改「并发会话数上限」(loop 入口持整 loop),用户决策修正「不设并发会话上限」后
|
||
/// 已删除 loop 入口 acquire。global 回归原义:由各单次 LLM 调用点(stream_llm 重试循环 / 标题 / 压缩 /
|
||
/// 提炼 / 项目分析扫描)各自 `acquire_global()` 拿 permit、调用结束 Drop 释放,防 provider 429。
|
||
/// 多对话 loop 并发不限数(用户接受 token 暴增)。
|
||
///
|
||
/// ## per_conv 改 HashMap<conv_id, Semaphore>
|
||
/// 原应用级单信号量(AiSession 单例 + generating 互斥下退化)改为
|
||
/// `HashMap<String, Arc<Semaphore>>`:每对话一份(permits=2,主循环 + 标题 + 提炼各自限流)。
|
||
/// `run_agentic_loop` 入口 `acquire_per_conv(conv_id)` 拿 1 permit 持整个 loop 生命周期(含工具执行/审批
|
||
/// 等待/重试),防单对话内并发 LLM 调用失控。这是单对话内限流,非会话数限制,符合用户「不设上限」。
|
||
/// `acquire_per_conv(conv_id)`:lock HashMap → 无则建(permits=2)→ clone Arc → 释放 lock → acquire_owned。
|
||
/// conv 退出清理(`release_conv(conv_id)`):conv 删除时 remove 条目(防 HashMap 无限增长)。
|
||
///
|
||
/// 运行时调整:tokio Semaphore 的 permits 数构造时固定、不可增减,
|
||
/// 故 `global` 用 `Arc<Mutex<Arc<Semaphore>>>` 双层包装——替换内层 Arc 即重建 Semaphore。
|
||
/// 已持有旧 permit 的任务不受影响(permit 绑定旧 Semaphore,软收敛),
|
||
/// 新请求 lock 后克隆到最新 Arc、自动走新限制。旧 Semaphore 随最后 permit 释放而 drop。
|
||
/// per_conv HashMap 内每条 Arc<Semaphore> 不需运行时改 permits(对话内并发上限 2 固定),故无重建需求。
|
||
///
|
||
/// ## F-260614-04: per-provider 层(可选)
|
||
/// `per_provider` 为 HashMap<provider_id, Arc<Semaphore>>。调用方(agentic loop)经
|
||
/// `acquire_for_provider(pid)` 取额外 permit,防单 provider 被打满(限流 429)。
|
||
/// **单 provider 场景**:若未调 `set_provider_caps`,HashMap 为空,
|
||
/// `acquire_for_provider` 返回 None(无限流,行为同 F-01 前)。零变化保证。
|
||
/// 全局容量 = min(sum(各 provider 上限), global_cap):由调用方在配置时约束
|
||
/// (set_provider_caps 传 min(sum, global_cap)),非运行时强约束。
|
||
#[derive(Clone)]
|
||
pub struct LlmConcurrency {
|
||
/// 全局 LLM 调用并发上限(permits 默认 3):F-09 batch5 修正后回归原义,由各单次 LLM 调用点
|
||
/// (stream_llm/标题/压缩/提炼/项目分析)各自 acquire/drop 防 429,不再由 loop 入口持整 loop。
|
||
global: Arc<Mutex<Arc<Semaphore>>>,
|
||
/// 单对话内并发上限(F-09 B 批5):HashMap<conv_id, Semaphore>,每对话 permits=2。
|
||
/// acquire 时按 conv_id 取/建;release_conv 在 loop 结束 + 无 pending 时 remove。
|
||
per_conv: Arc<Mutex<HashMap<String, Arc<Semaphore>>>>,
|
||
/// 每对话内并发上限(permits 默认 2,构造时传入 new(3, 2) 的第二参)。
|
||
/// F-09 B 批5:AtomicUsize 支持运行时热改(ai_set_concurrency_config 的 per_conv_limit)。
|
||
/// 热改后**已建对话**的旧 Semaphore 不变(permits 构造时固定),**新建对话**用新值;
|
||
/// 为使热改立即全量生效,set_per_conv 同时清空 HashMap 强制重建(软收敛:旧 permit 随 Drop 释放)。
|
||
per_conv_permits: Arc<AtomicUsize>,
|
||
/// F-260614-04: per-provider 信号量表。空 = 无 per-provider 限流(单 provider 路径零变化)。
|
||
/// Arc<Mutex<HashMap>>:运行时增删 provider 配置时替换/插入,acquire 时 clone Arc。
|
||
per_provider: Arc<Mutex<HashMap<String, Arc<Semaphore>>>>,
|
||
}
|
||
|
||
impl LlmConcurrency {
|
||
pub fn new(global: usize, per_conv: usize) -> Self {
|
||
Self {
|
||
global: Arc::new(Mutex::new(Arc::new(Semaphore::new(global)))),
|
||
per_conv: Arc::new(Mutex::new(HashMap::new())),
|
||
per_conv_permits: Arc::new(AtomicUsize::new(per_conv)),
|
||
per_provider: Arc::new(Mutex::new(HashMap::new())),
|
||
}
|
||
}
|
||
|
||
/// 取全局 LLM 调用并发 permit:由各单次 LLM 调用点(stream_llm/标题/压缩/提炼/项目分析)
|
||
/// 各自调用、调用结束 Drop 释放。F-09 batch5 修正后不再由 run_agentic_loop 入口持整 loop。
|
||
/// 重建后(set_global)新请求自动走最新 Semaphore。
|
||
pub async fn acquire_global(&self) -> tokio::sync::OwnedSemaphorePermit {
|
||
let sema = self.global.lock().await.clone();
|
||
sema.acquire_owned().await.expect("llm global semaphore closed")
|
||
}
|
||
|
||
/// 取单对话内并发 permit(F-09 B 批5):按 conv_id 取/建 Semaphore,permits=当前 per_conv_permits(默认 2)。
|
||
/// lock HashMap → 无则建 → clone Arc → 释放 lock → acquire_owned。锁持有短(不含 await acquire)。
|
||
pub async fn acquire_per_conv(&self, conv_id: &str) -> tokio::sync::OwnedSemaphorePermit {
|
||
let sema = {
|
||
let mut map = self.per_conv.lock().await;
|
||
let permits = self.per_conv_permits.load(std::sync::atomic::Ordering::SeqCst);
|
||
map.entry(conv_id.to_string())
|
||
.or_insert_with(|| Arc::new(Semaphore::new(permits)))
|
||
.clone()
|
||
};
|
||
sema.acquire_owned().await.expect("llm per_conv semaphore closed")
|
||
}
|
||
|
||
/// F-09 B 批5:conv 退出清理。loop 结束 + 无 pending 审批时 remove 该 conv 的 Semaphore 条目。
|
||
/// **时机由调用方判断**(agentic loop 退出点):仅在确信无后续 acquire 时调用,否则误删会致
|
||
/// 该 conv 下次 acquire 重建 Semaphore(限流计数清零,非致命,但语义偏离)。
|
||
/// remove 后已持 permit 不受影响(permit 绑旧 Arc,随 Drop 释放),仅阻止新条目累积。
|
||
pub async fn release_conv(&self, conv_id: &str) {
|
||
self.per_conv.lock().await.remove(conv_id);
|
||
}
|
||
|
||
/// 重建全局 Semaphore(软收敛:旧 permit 不回收,待其释放后新限制完全生效)
|
||
pub async fn set_global(&self, permits: usize) {
|
||
*self.global.lock().await = Arc::new(Semaphore::new(permits));
|
||
}
|
||
|
||
/// 重建单对话 Semaphore(F-09 B 批5 后 per_conv 为 HashMap)。
|
||
/// 行为:更新 per_conv_permits(AtomicUsize) + 清空 HashMap(软收敛:旧 permit 随 Drop 释放,
|
||
/// 新对话 acquire 用新 permits 值重建 Semaphore)。已建对话若仍在跑,旧 Semaphore 不变;
|
||
/// 下次该 conv 新 acquire 时因 HashMap 已清空会重建为新 permits。
|
||
pub async fn set_per_conv(&self, permits: usize) {
|
||
self.per_conv_permits
|
||
.store(permits, std::sync::atomic::Ordering::SeqCst);
|
||
self.per_conv.lock().await.clear();
|
||
}
|
||
|
||
/// F-260614-04: 取 per-provider 并发 permit(可选)。
|
||
///
|
||
/// - provider 在 `per_provider` 表中有配置 → 取其 Semaphore permit,返回 Some。
|
||
/// - provider 无配置(单 provider 场景或未 set_provider_caps)→ 返回 None,无限流。
|
||
///
|
||
/// 调用方(agentic loop)用法:
|
||
/// ```ignore
|
||
/// let _global_permit = llm_concurrency.acquire_global().await;
|
||
/// let _per_conv_permit = llm_concurrency.acquire_per_conv(&conv_id).await;
|
||
/// let _provider_permit = llm_concurrency.acquire_for_provider(&provider_id).await;
|
||
/// ```
|
||
/// 三 permit 均绑 guard Drop 自动释放。None 时无 permit 需释放(行为同 F-01 前)。
|
||
pub async fn acquire_for_provider(
|
||
&self,
|
||
provider_id: &str,
|
||
) -> Option<tokio::sync::OwnedSemaphorePermit> {
|
||
let sema = {
|
||
let map = self.per_provider.lock().await;
|
||
map.get(provider_id).cloned()
|
||
}?;
|
||
// Semaphore 存在 → acquire。expect 同 acquire_global/per_conv:Semaphore 不会 close
|
||
// (无 close() 调用,仅在 set_provider_caps 时替换表内 Arc,旧 Arc permit 仍有效)。
|
||
Some(
|
||
sema.acquire_owned()
|
||
.await
|
||
.expect("llm per_provider semaphore closed"),
|
||
)
|
||
}
|
||
|
||
/// F-260614-04: 批量设置 per-provider 并发上限(替换整表)。
|
||
///
|
||
/// 调用方(Settings 配置热改 / 启动初始化)传入 `{ provider_id: permits }` map,
|
||
/// 替换整张 per_provider 表(软收敛:已持 permit 不回收)。全局容量约束 min(sum, global_cap)
|
||
/// 由调用方在构造 map 时应用(本函数不做强约束,只落表)。
|
||
///
|
||
/// 传空 map → 清空 per_provider 表(所有 provider 回退到无 per-provider 限流)。
|
||
pub async fn set_provider_caps(&self, caps: HashMap<String, usize>) {
|
||
let mut map = self.per_provider.lock().await;
|
||
map.clear();
|
||
for (pid, permits) in caps {
|
||
// permits=0 等同无配置(Semaphore::new(0) 永远 acquire 不到 → 死锁),
|
||
// 故 permits=0 跳过(不落表 → acquire_for_provider 返 None → 无限流)。
|
||
if permits > 0 {
|
||
map.insert(pid, Arc::new(Semaphore::new(permits)));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 应用全局状态 — 通过 `app.manage()` 注入,command 中以 `State<'_, AppState>` 取用
|
||
pub struct AppState {
|
||
/// 数据库句柄(Arc 包装,便于在异步任务中重建 Repo)
|
||
pub db: Arc<Database>,
|
||
/// 灵感表 Repo
|
||
pub ideas: IdeaRepo,
|
||
/// 项目表 Repo
|
||
pub projects: ProjectRepo,
|
||
/// 任务表 Repo
|
||
pub tasks: TaskRepo,
|
||
/// 发布表 Repo(预留:ReleaseRepo 持久化已就位·IPC/逻辑未接入·SW-260618-21 b 保留)
|
||
#[allow(dead_code)]
|
||
pub releases: ReleaseRepo,
|
||
/// 工作流执行表 Repo
|
||
pub workflows: WorkflowRepo,
|
||
/// 节点执行表 Repo(预留:NodeExecutionRepo 持久化已就位·IPC/逻辑未接入·SW-260618-21 b 保留)
|
||
#[allow(dead_code)]
|
||
pub node_executions: NodeExecutionRepo,
|
||
/// 工作流事件总线(tokio broadcast)
|
||
pub event_bus: EventBus,
|
||
/// 节点注册表(已注册内置节点)
|
||
pub registry: Arc<NodeRegistry>,
|
||
// ── AI ──
|
||
/// AI 提供商配置 Repo
|
||
pub ai_providers: AiProviderRepo,
|
||
/// AI 对话历史 Repo
|
||
pub ai_conversations: AiConversationRepo,
|
||
/// AI 工具执行审计 Repo
|
||
pub ai_tool_executions: AiToolExecutionRepo,
|
||
/// AI 工具注册表
|
||
pub ai_tools: Arc<AiToolRegistry>,
|
||
/// AI 会话状态
|
||
pub ai_session: Arc<Mutex<AiSession>>,
|
||
// ── 知识库 ──
|
||
/// 知识库 Repo
|
||
pub knowledge: KnowledgeRepo,
|
||
/// 知识生命线事件 Repo(产生/审核/引用/归档审计)
|
||
pub knowledge_events: KnowledgeEventsRepo,
|
||
/// 知识库行为配置(提取 + 注入)
|
||
pub knowledge_config: Arc<Mutex<KnowledgeConfig>>,
|
||
/// 通用应用设置 KV Repo(前端 localStorage 迁移目标)
|
||
pub settings: SettingsRepo,
|
||
// ── LLM 并发控制 ──
|
||
/// LLM 调用并发上限(全局 + 单对话双层 Semaphore,运行时可调)
|
||
pub llm_concurrency: LlmConcurrency,
|
||
// ── Agentic 循环轮次上限 ──
|
||
/// Agentic 循环最大轮次(前端 Settings 数字配置 → AppState 字段 → 热改 command →
|
||
/// loop 入口 load 快照透传形参;当前 loop 锁定边界,热改下次发消息生效)。
|
||
/// 默认 10,与 agentic.rs::DEFAULT_MAX_AGENT_ITERATIONS 对齐。
|
||
pub agent_max_iterations: Arc<AtomicUsize>,
|
||
// ── 流式对话失败自动重试 ──
|
||
/// 流式对话失败自动重试次数(F-260616-07 / 决策 a1:只重试流前失败 Init Err——未输出
|
||
/// 任何 token;流中途失败 MidStream Partial 保文不重试。退避复用 retry::backoff_delay +
|
||
/// is_status_retryable Fatal 分类 + 30s 总预算,详见 agentic.rs 重试循环。默认 3)。
|
||
pub agent_max_retries: Arc<AtomicUsize>,
|
||
// ── 工作流执行状态 ──
|
||
/// 工作流执行 → 节点状态机注册表
|
||
///
|
||
/// run_workflow 创建执行器后注册其 state_machine(StateMachine 内部 Arc<Mutex>,
|
||
/// clone 共享底层 HashMap,与下沉到 NodeContext.node_status 的是同一份);
|
||
/// cancel_workflow_node IPC 经 execution_id 取出后 set_cancelled,
|
||
/// 直达运行中 HumanNode 的 is_cancelled 轮询。执行完成(成功/失败)后移除条目。
|
||
pub workflow_state_registry: Arc<Mutex<HashMap<String, StateMachine>>>,
|
||
// ── F-260619-03 Phase A: AI 工具文件访问授权目录白名单 ──
|
||
/// AI 文件工具(read/write/list/patch/...)可访问的授权目录池。
|
||
/// Phase A 仅持久化白名单(Settings KV `allowed_dirs` 加载),
|
||
/// workspace_root 始终在白名单(向后兼容)。
|
||
/// Phase B 将扩展为 persistent + session(会话临时授权) + 弹窗挂起。
|
||
pub allowed_dirs: Arc<RwLock<AllowedDirs>>,
|
||
}
|
||
|
||
/// F-260619-03 Phase A/B/C: AI 工具文件访问授权目录白名单
|
||
///
|
||
/// - Phase A:`persistent`(持久化白名单,从 Settings KV `allowed_dirs` 加载,
|
||
/// JSON 数组 `["E:/wk-lab/u-abc"]`)。`resolve_workspace_path` 校验时:
|
||
/// - workspace_root 始终视为已授权(向后兼容,默认根)
|
||
/// - 任一 persistent 目录 starts_with 命中即放行
|
||
/// - Phase B:`session`(进程级会话临时授权,弹窗"仅本次"写入;切换/新建/删除会话清空)。
|
||
/// 单用户桌面应用 active_conversation_id 单全局模型,session 字段随 active 切换清空,
|
||
/// 行为等价"当前活跃会话的临时授权"。handler 闭包(read lock 取快照)与
|
||
/// process_tool_calls 预校验均读此字段,两端一致。
|
||
/// - Phase C:黑名单(is_authorized 内,黑名单优先于白名单 — 命中即拒)。
|
||
#[derive(Debug, Clone, Default)]
|
||
pub struct AllowedDirs {
|
||
/// 持久化授权目录(Settings KV `allowed_dirs`,JSON 字符串数组)。
|
||
/// 已规范化(canonicalize 失败回退原字面量),便于 starts_with 精确比对。
|
||
pub persistent: HashSet<PathBuf>,
|
||
/// F-260619-03 Phase B: 会话级临时授权目录(弹窗"仅本次"写入,进程级内存)。
|
||
/// 仅当前活跃会话生效,切换/新建/删除会话由 clear_session_allowed_dirs 清空(不落库)。
|
||
/// handler 闭包与 process_tool_calls 预校验均读此字段,确保两端授权判定一致。
|
||
pub session: HashSet<PathBuf>,
|
||
}
|
||
|
||
impl AllowedDirs {
|
||
/// Settings KV key:F-260619-03 Phase A 持久化白名单(JSON 字符串数组)
|
||
pub const SETTINGS_KEY: &'static str = "allowed_dirs";
|
||
|
||
/// 仅含 workspace_root 的默认白名单(向后兼容:无 allowed_dirs 时行为不变)。
|
||
/// 用于 mod.rs trust_key_for 等无白名单上下文的旧路径(零回归)。
|
||
pub fn default_with_root() -> Self {
|
||
let mut set = HashSet::new();
|
||
set.insert(workspace_root_path());
|
||
Self { persistent: set, session: HashSet::new() }
|
||
}
|
||
|
||
/// 路径是否被授权:workspace_root 始终授权 + persistent 或 session 任一 starts_with 命中即放行。
|
||
///
|
||
/// **Phase C 黑名单优先**:即使白名单命中,若路径落入系统敏感目录(Windows
|
||
/// `\Windows\System32` / `\Program Files\`;Unix `/etc /usr /bin /sbin /boot
|
||
/// /dev /proc /sys`)仍拒。黑名单优先于白名单,防用户误授权系统目录。
|
||
///
|
||
/// 输入 `candidate` 应为 canonicalize 后的真实路径(防 symlink 逃逸);
|
||
/// 调用方(resolve_workspace_path_with_allowed)负责 canonicalize,本函数只做 starts_with 比对。
|
||
pub fn is_authorized(&self, candidate: &Path) -> bool {
|
||
// Phase C: 黑名单优先(白名单命中也拒)。validate_path 已挡 .ssh/.aws 等,
|
||
// 此处补系统核心目录(用户可能误把 C:\ 加入 persistent,黑名单兜底拒 System32)。
|
||
if is_in_system_blacklist(candidate) {
|
||
return false;
|
||
}
|
||
let root = workspace_root_path();
|
||
if candidate.starts_with(&root) {
|
||
return true;
|
||
}
|
||
self.persistent.iter().any(|d| candidate.starts_with(d))
|
||
|| self.session.iter().any(|d| candidate.starts_with(d))
|
||
}
|
||
}
|
||
|
||
/// F-260619-03 Phase C: 路径授权决策(预校验用,process_tool_calls 分类前调)。
|
||
///
|
||
/// `check_path_authorization` 返回本枚举,process_tool_calls 据此决定:
|
||
/// - `Authorized`:路径已授权 → 走原 Low/Med/High 流程
|
||
/// - `NeedsAuthorization`:路径未授权但非黑名单 → Phase B 挂起弹窗(emit AiDirAuthRequired)
|
||
/// - `Denied`:路径命中黑名单 → 硬拒(工具返 Err tool_result,不挂起)
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub enum PathAuthDecision {
|
||
/// 已授权(workspace_root / persistent / session 命中且非黑名单)
|
||
Authorized,
|
||
/// 未授权且非黑名单:需 Phase B 弹窗询问用户。附带规范化后的待授权目录(父目录,
|
||
/// 对齐 session_trust 目录粒度),供"仅本次/未来都允许"写入白名单。
|
||
NeedsAuthorization { dir: PathBuf },
|
||
/// 命中系统黑名单(Phase C):硬拒,工具返 Err,不弹窗。
|
||
Denied { reason: String },
|
||
}
|
||
|
||
/// F-260619-03 Phase C: 系统敏感目录黑名单判定(跨平台)。
|
||
///
|
||
/// 白名单命中但黑名单命中 → 拒(防用户误把整个盘符如 `C:\` 加入 persistent 致
|
||
/// System32 被放行)。validate_path(tool_registry.rs)已挡 .ssh/.aws/AppData 等,
|
||
/// 此处补系统核心目录。
|
||
///
|
||
/// - Windows(不区分大小写,按路径分隔符分段):`\Windows\System32`、`\Program Files\`、
|
||
/// `\Program Files (x86)\`、`\Windows\` 根下 System/SysWOW64
|
||
/// - Unix:`/etc`、`/usr`、`/bin`、`/sbin`、`/boot`、`/dev`、`/proc`、`/sys`
|
||
pub(crate) fn is_in_system_blacklist(path: &Path) -> bool {
|
||
let s = path.to_string_lossy().to_lowercase();
|
||
let seps = ['/', '\\'];
|
||
// Windows:按分隔符分段判定(避免 contains 误伤 "my program files backup" 这类目录名)
|
||
let segs: Vec<&str> = s.split(seps).filter(|s| !s.is_empty()).collect();
|
||
for (i, seg) in segs.iter().enumerate() {
|
||
// Windows 系统目录:C:\Windows\System32 / C:\Windows\SysWOW64 / C:\Windows\System
|
||
if cfg!(windows) {
|
||
if *seg == "windows" {
|
||
if let Some(next) = segs.get(i + 1) {
|
||
if matches!(*next, "system32" | "syswow64" | "system") {
|
||
return true;
|
||
}
|
||
}
|
||
}
|
||
// C:\Program Files / C:\Program Files (x86)
|
||
if *seg == "program files" || *seg == "program files (x86)" {
|
||
return true;
|
||
}
|
||
}
|
||
// Unix 系统目录(路径首段为这些即拒;Windows 上也防 Unix 风格绝对路径,防御性)
|
||
if i == 0 && matches!(*seg, "etc" | "usr" | "bin" | "sbin" | "boot" | "dev" | "proc" | "sys") {
|
||
return true;
|
||
}
|
||
}
|
||
false
|
||
}
|
||
|
||
/// F-260619-03 Phase B/C: 路径授权预校验(供 process_tool_calls 分类前调)。
|
||
///
|
||
/// 词法层判定(不 canonicalize,因路径可能不存在 — write_file 新建)。返回三态决策:
|
||
/// - 路径规范化(去 .. / 锚定 workspace_root)后,若命中黑名单 → `Denied`
|
||
/// - 否则若 `is_authorized(规范化路径)`(persistent + session) → `Authorized`
|
||
/// - 否则 → `NeedsAuthorization { dir: 父目录规范化 }`(目录粒度,对齐 session_trust)
|
||
///
|
||
/// 注意:本函数只做词法层预判(防不存在路径兜底);实际执行时 handler 内
|
||
/// resolve_workspace_path_with_allowed 仍做完整校验(canonicalize symlink 防逃逸)。
|
||
/// 黑名单在两处都判(is_authorized 内 + 此处独立判),双保险。
|
||
pub fn check_path_authorization(
|
||
raw_path: &str,
|
||
allowed: &AllowedDirs,
|
||
) -> PathAuthDecision {
|
||
// 规范化:绝对路径原样,相对路径锚定 workspace_root(与 resolve_workspace_path_impl 一致)
|
||
let resolved = if Path::new(raw_path).is_absolute() {
|
||
PathBuf::from(raw_path)
|
||
} else {
|
||
workspace_root_path().join(raw_path)
|
||
};
|
||
// Phase C: 黑名单优先独立判定(is_authorized 内也判,此处先判便于 NeedsAuthorization
|
||
// 不误把黑名单路径推到弹窗 — 黑名单路径直接硬拒不让用户"授权")。
|
||
if is_in_system_blacklist(&resolved) {
|
||
return PathAuthDecision::Denied {
|
||
reason: format!("路径命中系统敏感目录黑名单: {}", raw_path),
|
||
};
|
||
}
|
||
if allowed.is_authorized(&resolved) {
|
||
return PathAuthDecision::Authorized;
|
||
}
|
||
// 未授权 → 取父目录作待授权目录(目录粒度,对齐 session_trust 的 dir 语义)
|
||
let dir = resolved
|
||
.parent()
|
||
.map(|p| p.to_path_buf())
|
||
.unwrap_or_else(|| workspace_root_path());
|
||
PathAuthDecision::NeedsAuthorization { dir }
|
||
}
|
||
|
||
/// workspace 根目录(src-tauri 上两级,与 tool_registry::workspace_root 同源)。
|
||
fn workspace_root_path() -> PathBuf {
|
||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||
.parent()
|
||
.and_then(|p| p.parent())
|
||
.map(PathBuf::from)
|
||
.unwrap_or_else(|| PathBuf::from("."))
|
||
}
|
||
|
||
impl AppState {
|
||
/// 初始化应用状态:打开(或创建)数据库并执行迁移,构建各 Repo 与节点注册表
|
||
pub async fn init(db_path: &Path) -> Result<Self> {
|
||
let db = Arc::new(Database::open(db_path).await?);
|
||
// F-260619-03 Phase A: build_ai_tool_registry 注入 allowed_dirs Arc,
|
||
// 文件工具闭包捕获后 resolve_workspace_path_with_allowed 校验动态白名单。
|
||
// 此处用 default_with_root 占位,下方 init 尾部 reload_allowed_dirs 从 Settings KV 覆盖。
|
||
let allowed_dirs = Arc::new(RwLock::new(AllowedDirs::default_with_root()));
|
||
let ai_tools = Arc::new(crate::commands::ai::build_ai_tool_registry(&db, &allowed_dirs));
|
||
// build_registry 需注入 Arc<Database>(TaskAdvanceNode 持 db)。
|
||
// 在 struct 字段 `db` move 前 clone,避免 E0382。
|
||
let registry = Arc::new(build_registry(db.clone()));
|
||
let state = Self {
|
||
ideas: IdeaRepo::new(&db),
|
||
projects: ProjectRepo::new(&db),
|
||
tasks: TaskRepo::new(&db),
|
||
releases: ReleaseRepo::new(&db),
|
||
workflows: WorkflowRepo::new(&db),
|
||
node_executions: NodeExecutionRepo::new(&db),
|
||
ai_providers: AiProviderRepo::new(&db),
|
||
ai_conversations: AiConversationRepo::new(&db),
|
||
ai_tool_executions: AiToolExecutionRepo::new(&db),
|
||
ai_session: Arc::new(Mutex::new(AiSession::new())),
|
||
knowledge: KnowledgeRepo::new(&db),
|
||
knowledge_events: KnowledgeEventsRepo::new(&db),
|
||
knowledge_config: Arc::new(Mutex::new(KnowledgeConfig::default())),
|
||
settings: SettingsRepo::new(&db),
|
||
llm_concurrency: LlmConcurrency::new(3, 2),
|
||
agent_max_iterations: Arc::new(AtomicUsize::new(
|
||
crate::commands::ai::agentic::DEFAULT_MAX_AGENT_ITERATIONS,
|
||
)),
|
||
agent_max_retries: Arc::new(AtomicUsize::new(
|
||
crate::commands::ai::agentic::DEFAULT_MAX_AGENT_RETRIES,
|
||
)),
|
||
workflow_state_registry: Arc::new(Mutex::new(HashMap::new())),
|
||
// F-260619-03 Phase A: 与 ai_tools registry 共享同一 Arc(构建时注入同一句柄)
|
||
allowed_dirs: allowed_dirs.clone(),
|
||
db,
|
||
event_bus: EventBus::new(),
|
||
registry,
|
||
ai_tools,
|
||
};
|
||
// 启动恢复:重启前卡 pending 的工具审批(内存 pending_approvals 已丢)从审计表重建,
|
||
// 使重启后待审批不丢。前端经 ai_pending_tool_calls + switchConversation 恢复 toolCard 态。
|
||
crate::commands::ai::restore_pending_approvals(&state).await;
|
||
// F-260614-04c: 启动一次性初始化 per-provider caps 表。
|
||
// 根据 DB enabled providers 建 HashMap<provider_id, global_cap>,让 agentic loop 的
|
||
// acquire_for_provider 从 None(无限流)切换到 Some(按配置限流)。disabled / weight=0
|
||
// 的 provider 不入表(其被 provider_pool::select 过滤出候选,不会被 acquire)。
|
||
// 单 provider 场景:该 provider cap=global_cap → acquire_global+acquire_for_provider
|
||
// 串联,min(3,3)=3,有效上限同未配置 → 行为零变化。
|
||
state.reload_provider_caps().await;
|
||
// F-260619-03 Phase A: 从 Settings KV 加载持久化授权目录白名单覆盖默认值。
|
||
// 失败(读 KV/解析 JSON 出错)不阻断启动,保持 default_with_root(仅 workspace_root)。
|
||
state.reload_allowed_dirs().await;
|
||
Ok(state)
|
||
}
|
||
|
||
/// F-260614-04c: 从 DB enabled providers 重建 per_provider caps 并 set_provider_caps。
|
||
///
|
||
/// 启动 + provider 变更(ai_update_provider_pool / 删除 / 新增)后调用。caps 策略见
|
||
/// AppState::init 注释(本轮每 provider cap = global_cap,F-04d 配差异化上限时仅改此)。
|
||
pub async fn reload_provider_caps(&self) {
|
||
let providers = match self.ai_providers.list_all().await {
|
||
Ok(ps) => ps,
|
||
Err(e) => {
|
||
tracing::warn!(
|
||
"[F-04c] 读取 providers 重建 caps 失败,保持当前 caps 表不变: {}", e
|
||
);
|
||
return;
|
||
}
|
||
};
|
||
let global_cap = 3; // 与 LlmConcurrency::new(3, 2) 的 global 上限对齐。
|
||
let caps: HashMap<String, usize> = providers
|
||
.into_iter()
|
||
.filter(|p| p.enabled && p.weight > 0)
|
||
.map(|p| (p.id, global_cap))
|
||
.collect();
|
||
self.llm_concurrency.set_provider_caps(caps).await;
|
||
}
|
||
|
||
/// F-260619-03 Phase A: 从 Settings KV `allowed_dirs`(JSON 字符串数组)加载持久化白名单。
|
||
///
|
||
/// 启动 + Settings IPC `ai_set_allowed_dirs` 写入后调用。解析失败/缺失 → 保持
|
||
/// default_with_root(仅 workspace_root,向后兼容:is_authorized 始终放行 workspace_root)。
|
||
/// 每条路径尝试 canonicalize 规范化(防大小写/分隔符差异绕过);canonicalize 失败
|
||
/// (目录不存在)回退原字面量 trim(写入后再校验场景:先授权目录路径,目录暂不存在)。
|
||
pub async fn reload_allowed_dirs(&self) {
|
||
let raw = match self.settings.get(AllowedDirs::SETTINGS_KEY).await {
|
||
Ok(Some(v)) => v,
|
||
Ok(None) => {
|
||
// 未配置:保持 default_with_root(is_authorized 始终放行 workspace_root)
|
||
return;
|
||
}
|
||
Err(e) => {
|
||
tracing::warn!("[F-03A] 读取 allowed_dirs 失败,保持默认: {}", e);
|
||
return;
|
||
}
|
||
};
|
||
let dirs: Vec<String> = match serde_json::from_str(&raw) {
|
||
Ok(v) => v,
|
||
Err(e) => {
|
||
tracing::warn!("[F-03A] allowed_dirs 非 JSON 字符串数组,保持默认: {}", e);
|
||
return;
|
||
}
|
||
};
|
||
let mut set = HashSet::new();
|
||
// workspace_root 始终在白名单(向后兼容);不重复插入
|
||
set.insert(workspace_root_path());
|
||
for d in dirs {
|
||
let d = d.trim();
|
||
if d.is_empty() {
|
||
continue;
|
||
}
|
||
let p = PathBuf::from(d);
|
||
// canonicalize 成功用真实路径(去 symlink/大小写归一);失败回退原字面量
|
||
let normalized = std::fs::canonicalize(&p).unwrap_or_else(|_| {
|
||
// 规范化分隔符 trim 尾部,保持与 starts_with 比对一致
|
||
PathBuf::from(d.trim_end_matches(['/', '\\']))
|
||
});
|
||
set.insert(normalized);
|
||
}
|
||
// F-260619-03 Phase B: reload 时保留当前会话临时授权(session 不落库,仅内存),
|
||
// 仅覆盖 persistent。用户改 Settings 不影响当前会话已临时授权的目录。
|
||
let mut guard = self.allowed_dirs.write().await;
|
||
let preserved_session = std::mem::take(&mut guard.session);
|
||
*guard = AllowedDirs { persistent: set, session: preserved_session };
|
||
}
|
||
|
||
/// F-260619-03 Phase A: 写 Settings KV + 同步内存白名单(供 Settings IPC 调用)。
|
||
///
|
||
/// - 持久化:JSON 字符串数组写 `app_settings` key=`allowed_dirs`
|
||
/// - 内存:reload_allowed_dirs 重新加载(规范化逻辑复用,避免双份)
|
||
/// 返回持久化后的规范化路径列表(供前端回显 canonicalize 后的真实路径)。
|
||
pub async fn set_allowed_dirs(&self, dirs: Vec<String>) -> Result<Vec<String>> {
|
||
// 去空 + 去重(保留顺序,前端展示友好)
|
||
let mut seen = HashSet::new();
|
||
let cleaned: Vec<String> = dirs
|
||
.into_iter()
|
||
.map(|s| s.trim().to_string())
|
||
.filter(|s| !s.is_empty())
|
||
.filter(|s| seen.insert(s.clone()))
|
||
.collect();
|
||
let json = serde_json::to_string(&cleaned)?;
|
||
self.settings.set(AllowedDirs::SETTINGS_KEY, &json).await
|
||
.map_err(|e| anyhow::anyhow!("持久化 allowed_dirs 失败: {}", e))?;
|
||
self.reload_allowed_dirs().await;
|
||
// 返回内存白名单(含 workspace_root)的规范化字符串列表(前端可看到真实生效路径)
|
||
let guard = self.allowed_dirs.read().await;
|
||
let mut out: Vec<String> = guard
|
||
.persistent
|
||
.iter()
|
||
.map(|p| p.to_string_lossy().to_string())
|
||
.collect();
|
||
out.sort();
|
||
Ok(out)
|
||
}
|
||
|
||
/// F-260619-03 Phase A: 读内存白名单为字符串列表(供 Settings IPC `ai_get_allowed_dirs` 回显)。
|
||
pub async fn get_allowed_dirs(&self) -> Vec<String> {
|
||
let guard = self.allowed_dirs.read().await;
|
||
let mut out: Vec<String> = guard
|
||
.persistent
|
||
.iter()
|
||
.map(|p| p.to_string_lossy().to_string())
|
||
.collect();
|
||
out.sort();
|
||
out
|
||
}
|
||
|
||
/// F-260619-03 Phase B: 追加持久化授权目录(弹窗"未来都允许"选项)。
|
||
///
|
||
/// 读当前 persistent → 追加新目录(去重)→ set_allowed_dirs 持久化 + 同步内存。
|
||
/// 返回持久化后规范化列表(含 workspace_root)。
|
||
pub async fn add_persistent_allowed_dir(&self, dir: String) -> Result<Vec<String>> {
|
||
let d = dir.trim().to_string();
|
||
if d.is_empty() {
|
||
return Ok(self.get_allowed_dirs().await);
|
||
}
|
||
let mut current = self.get_allowed_dirs().await;
|
||
if !current.iter().any(|c| paths_eq(c, &d)) {
|
||
current.push(d);
|
||
}
|
||
self.set_allowed_dirs(current).await
|
||
}
|
||
|
||
/// F-260619-03 Phase B: 追加会话级临时授权目录(弹窗"仅本次"选项)。
|
||
///
|
||
/// 写入 `allowed_dirs.session`(进程级内存,不落库)。handler 闭包 read lock 取快照时
|
||
/// 与 process_tool_calls 预校验读同一字段,两端授权判定一致。规范化:canonicalize
|
||
/// 失败回退原字面量 trim(与 reload_allowed_dirs 一致)。
|
||
pub async fn add_session_allowed_dir(&self, dir: String) {
|
||
let d = dir.trim();
|
||
if d.is_empty() {
|
||
return;
|
||
}
|
||
let p = PathBuf::from(d);
|
||
let normalized = std::fs::canonicalize(&p).unwrap_or_else(|_| {
|
||
PathBuf::from(d.trim_end_matches(['/', '\\']))
|
||
});
|
||
self.allowed_dirs.write().await.session.insert(normalized);
|
||
}
|
||
|
||
/// F-260619-03 Phase B: 清空会话级临时授权(切换/新建/删除活跃会话时调)。
|
||
///
|
||
/// 单用户桌面应用 active_conversation_id 单全局模型,session 字段语义为
|
||
/// "当前活跃会话的临时授权",切走即清空(不跨会话继承临时授权)。
|
||
pub async fn clear_session_allowed_dirs(&self) {
|
||
self.allowed_dirs.write().await.session.clear();
|
||
}
|
||
}
|
||
|
||
/// F-260619-03 Phase B: 路径字符串等价比较(canonicalize 后比对,失败回退小写比对)。
|
||
fn paths_eq(a: &str, b: &str) -> bool {
|
||
let pa = std::fs::canonicalize(a).map(|p| p.to_string_lossy().to_string()).unwrap_or_else(|_| a.to_string());
|
||
let pb = std::fs::canonicalize(b).map(|p| p.to_string_lossy().to_string()).unwrap_or_else(|_| b.to_string());
|
||
pa.eq_ignore_ascii_case(&pb)
|
||
}
|
||
|
||
/// 构建节点注册表 — 注册内置节点
|
||
///
|
||
/// 注意:不使用 `NodeRegistry::default()`,其 script 工厂为占位实现(会 panic),
|
||
/// 这里注册 df-nodes 提供的真实 HumanNode / AiNode。
|
||
///
|
||
/// "script" 节点不注册:ScriptNode 走 cmd /C | sh -c 执行 config.command 原始串,
|
||
/// 前端可构造任意 DagDef 触达无审批 shell(R-PD-2)。DevFlow 工作流当前为纯演示
|
||
/// 功能(前端唯一构造点 ProjectDetail.vue demoDag 三步 echo),无真实构建/部署脚本
|
||
/// 需求。需要脚本执行能力时新建独立 BuildNode(白名单 + 项目目录锚定 + 复用 AI 工具
|
||
/// RiskLevel 审批链),而非回头启用 ScriptNode + 黑名单。
|
||
/// 详见 docs/02-架构设计/专项设计/工作流脚本执行边界-2026-06-15.md。
|
||
fn build_registry(db: Arc<Database>) -> NodeRegistry {
|
||
let mut registry = NodeRegistry::new();
|
||
registry.register("human", |_config| {
|
||
Box::new(df_nodes::human_node::HumanNode)
|
||
});
|
||
// AiNode(df_nodes::ai_node, impl Node trait):
|
||
// 决策 a(AiNode 自审闭环)步骤②:AiNode 持 Arc<Database>,execute 完成后若有 task_id
|
||
// 则把产出落 task.output_json。工厂闭包 move 捕获 db 句柄(Arc clone 廉价)。
|
||
let ai_db = db.clone();
|
||
registry.register("ai", move |_config| {
|
||
Box::new(df_nodes::ai_node::AiNode::new(ai_db.clone()))
|
||
});
|
||
// AiSelfReviewNode(df_nodes::ai_node, impl Node trait):
|
||
// 决策 a 步骤③:四维度自审(prompt 固定+JSON 解析兜底),写回 output_json 加 review 子字段,
|
||
// review 摘要塞 NodeOutput.data 供下游 human_review 经 DAG inputs 透传。
|
||
// testing 模板 ai_self_review 节点类型对齐此注册 key。
|
||
let review_db = db.clone();
|
||
registry.register("ai_self_review", move |_config| {
|
||
Box::new(df_nodes::ai_node::AiSelfReviewNode::new(review_db.clone()))
|
||
});
|
||
// TaskAdvanceNode(df_nodes::task_advance_node, impl Node trait):
|
||
// 推进链阶段 2 工作流联动入口 — DAG 内触发 advance_task。
|
||
// 持有 Arc<Database> 在此构造时注入(NodeRegistry::register 工厂闭包 move 捕获 db,
|
||
// Arc clone 廉价),Node::execute 从 NodeContext.config 读 task_id/target_status。
|
||
// D-260616-03: 推进链/状态机/闸门走 df-nodes Node trait,非复活 df-task。
|
||
registry.register("task_advance", move |_config| {
|
||
Box::new(df_nodes::task_advance_node::TaskAdvanceNode::new(db.clone()))
|
||
});
|
||
registry
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
// ============================================================
|
||
// F-260619-03 Phase A: AllowedDirs 授权语义测试
|
||
// 锁定:workspace_root 始终授权 + persistent 命中放行 + 未命中拒绝。
|
||
// ============================================================
|
||
|
||
/// workspace_root 路径在 default_with_root 白名单内授权通过
|
||
#[test]
|
||
fn test_allowed_dirs_workspace_root_authorized() {
|
||
let allowed = AllowedDirs::default_with_root();
|
||
let root = workspace_root_path();
|
||
assert!(allowed.is_authorized(&root), "workspace_root 应被授权");
|
||
// workspace 内子路径也应授权(starts_with workspace_root)
|
||
let child = root.join("src").join("main.rs");
|
||
assert!(allowed.is_authorized(&child), "workspace_root 子路径应被授权");
|
||
}
|
||
|
||
/// 自定义授权目录命中放行(模拟用户授权 E:/some/external/dir)
|
||
#[test]
|
||
fn test_allowed_dirs_custom_authorized() {
|
||
let mut set = HashSet::new();
|
||
let custom = PathBuf::from("E:/wk-test-external-dir");
|
||
set.insert(custom.clone());
|
||
let allowed = AllowedDirs { persistent: set, session: HashSet::new() };
|
||
// 精确命中 + 子路径 starts_with 命中
|
||
assert!(allowed.is_authorized(&custom));
|
||
assert!(allowed.is_authorized(&custom.join("sub").join("file.txt")));
|
||
}
|
||
|
||
/// 未授权目录被拒绝
|
||
#[test]
|
||
fn test_allowed_dirs_unauthorized_rejected() {
|
||
let mut set = HashSet::new();
|
||
set.insert(PathBuf::from("E:/wk-test-authorized"));
|
||
let allowed = AllowedDirs { persistent: set, session: HashSet::new() };
|
||
let outside = PathBuf::from("E:/wk-test-unauthorized/file.txt");
|
||
assert!(!allowed.is_authorized(&outside), "未授权目录应被拒绝");
|
||
}
|
||
|
||
/// 默认(空 persistent)只授权 workspace_root
|
||
#[test]
|
||
fn test_allowed_dirs_default_empty_persistent() {
|
||
let allowed = AllowedDirs::default();
|
||
// 空 persistent,无 workspace_root → 任何路径都不授权
|
||
// (default_with_root 才含 workspace_root;Default 不含,用于边界测试)
|
||
assert!(!allowed.is_authorized(&PathBuf::from("E:/anything")));
|
||
}
|
||
|
||
/// SETTINGS_KEY 常量稳定(防 rename 致持久化数据丢失)
|
||
#[test]
|
||
fn test_allowed_dirs_settings_key_stable() {
|
||
assert_eq!(AllowedDirs::SETTINGS_KEY, "allowed_dirs");
|
||
}
|
||
|
||
// ============================================================
|
||
// F-260619-03 Phase B: session 临时授权语义测试
|
||
// ============================================================
|
||
|
||
/// Phase B: session 命中放行(persistent 未命中但 session 命中)
|
||
#[test]
|
||
fn test_allowed_dirs_session_authorized() {
|
||
let mut session = HashSet::new();
|
||
session.insert(PathBuf::from("E:/wk-temp-session"));
|
||
let allowed = AllowedDirs { persistent: HashSet::new(), session };
|
||
assert!(allowed.is_authorized(&PathBuf::from("E:/wk-temp-session/file.txt")));
|
||
}
|
||
|
||
/// Phase B: persistent + session 任一命中即放行
|
||
#[test]
|
||
fn test_allowed_dirs_persistent_or_session() {
|
||
let mut persistent = HashSet::new();
|
||
persistent.insert(PathBuf::from("E:/wk-persist"));
|
||
let mut session = HashSet::new();
|
||
session.insert(PathBuf::from("E:/wk-session"));
|
||
let allowed = AllowedDirs { persistent, session };
|
||
assert!(allowed.is_authorized(&PathBuf::from("E:/wk-persist/a")));
|
||
assert!(allowed.is_authorized(&PathBuf::from("E:/wk-session/b")));
|
||
assert!(!allowed.is_authorized(&PathBuf::from("E:/wk-other/c")));
|
||
}
|
||
|
||
// ============================================================
|
||
// F-260619-03 Phase C: 系统目录黑名单测试
|
||
// ============================================================
|
||
|
||
/// Phase C: Windows System32 黑名单命中(即使在白名单内也拒)
|
||
#[test]
|
||
fn test_blacklist_windows_system32() {
|
||
let mut persistent = HashSet::new();
|
||
// 用户误把整个 C:\ 加入白名单
|
||
persistent.insert(PathBuf::from("C:\\"));
|
||
let allowed = AllowedDirs { persistent, session: HashSet::new() };
|
||
// System32 应被黑名单拒(尽管 C:\ 在白名单)
|
||
assert!(!allowed.is_authorized(&PathBuf::from("C:\\Windows\\System32\\config\\sam")));
|
||
}
|
||
|
||
/// Phase C: Windows Program Files 黑名单命中
|
||
#[test]
|
||
fn test_blacklist_windows_program_files() {
|
||
let mut persistent = HashSet::new();
|
||
persistent.insert(PathBuf::from("C:\\"));
|
||
let allowed = AllowedDirs { persistent, session: HashSet::new() };
|
||
assert!(!allowed.is_authorized(&PathBuf::from("C:\\Program Files\\SomeApp\\app.exe")));
|
||
}
|
||
|
||
/// Phase C: 黑名单不误伤合法目录(含 "program files" 子串的自定义目录名)
|
||
#[test]
|
||
fn test_blacklist_no_false_positive() {
|
||
// "my program files backup" 不应被拒(分段匹配,非精确段)
|
||
assert!(!is_in_system_blacklist(&PathBuf::from("E:/my program files backup/x")));
|
||
// 普通工作目录不拒
|
||
assert!(!is_in_system_blacklist(&PathBuf::from("E:/wk-lab/devflow/src")));
|
||
}
|
||
|
||
/// Phase C: Unix 系统目录黑名单(/etc /usr /bin 等)
|
||
#[test]
|
||
fn test_blacklist_unix_system_dirs() {
|
||
assert!(is_in_system_blacklist(&PathBuf::from("/etc/passwd")));
|
||
assert!(is_in_system_blacklist(&PathBuf::from("/usr/bin/python")));
|
||
assert!(is_in_system_blacklist(&PathBuf::from("/proc/self/environ")));
|
||
assert!(is_in_system_blacklist(&PathBuf::from("/sys/kernel")));
|
||
// 普通用户目录不拒
|
||
assert!(!is_in_system_blacklist(&PathBuf::from("/home/user/project")));
|
||
}
|
||
|
||
// ============================================================
|
||
// F-260619-03 Phase B/C: check_path_authorization 三态决策测试
|
||
// ============================================================
|
||
|
||
/// Phase B/C: workspace_root 路径 → Authorized
|
||
#[test]
|
||
fn test_check_path_authorized_workspace() {
|
||
let allowed = AllowedDirs::default_with_root();
|
||
let rel = "src/main.rs";
|
||
match check_path_authorization(rel, &allowed) {
|
||
PathAuthDecision::Authorized => {}
|
||
other => panic!("workspace_root 内路径应 Authorized, got {:?}", other),
|
||
}
|
||
}
|
||
|
||
/// Phase B/C: 未授权路径 → NeedsAuthorization(附父目录)
|
||
#[test]
|
||
fn test_check_path_needs_auth() {
|
||
let allowed = AllowedDirs::default(); // 空,无 workspace_root
|
||
match check_path_authorization("E:/wk-external/file.txt", &allowed) {
|
||
PathAuthDecision::NeedsAuthorization { dir } => {
|
||
assert_eq!(dir, PathBuf::from("E:/wk-external"));
|
||
}
|
||
other => panic!("未授权路径应 NeedsAuthorization, got {:?}", other),
|
||
}
|
||
}
|
||
|
||
/// Phase B: session 命中 → Authorized(check_path_authorization 读 session 字段)
|
||
#[test]
|
||
fn test_check_path_session_hit() {
|
||
let mut session = HashSet::new();
|
||
session.insert(PathBuf::from("E:/wk-session"));
|
||
let allowed = AllowedDirs { persistent: HashSet::new(), session };
|
||
match check_path_authorization("E:/wk-session/sub/file.txt", &allowed) {
|
||
PathAuthDecision::Authorized => {}
|
||
other => panic!("session 命中应 Authorized, got {:?}", other),
|
||
}
|
||
}
|
||
|
||
/// Phase C: 黑名单路径 → Denied(不弹窗直接拒)
|
||
#[test]
|
||
fn test_check_path_denied_blacklist() {
|
||
let allowed = AllowedDirs::default_with_root();
|
||
match check_path_authorization("C:/Windows/System32/config/sam", &allowed) {
|
||
PathAuthDecision::Denied { .. } => {}
|
||
other => panic!("System32 应 Denied, got {:?}", other),
|
||
}
|
||
}
|
||
}
|
||
|