新增: AI 工具(download_file + http output_file + grep 窗口 + fetch_search 搜索 + get_app_config + generate_image)
download_file 跨平台 URL 到文件流式下载;http_request output_file 落盘 + 截断可配; grep context_chars 大单行窗口;fetch_search DuckDuckGo 免 key 搜索;get_app_config 只读返当前配置(治 AI 查配置绕 PowerShell);generate_image SenseNova 图像生成。
This commit is contained in:
@@ -301,6 +301,52 @@ pub(crate) fn compute_file_hash(meta: &std::fs::Metadata) -> String {
|
||||
format!("{}_{}", modified.unwrap_or(0), meta.len())
|
||||
}
|
||||
|
||||
/// 将原始字节解码为 UTF-8 字符串,优先识别 UTF-16 LE/BE BOM 解码转 UTF-8。
|
||||
///
|
||||
/// 背景(P1 修复):PowerShell `Out-File`/`>` 重定向默认产 UTF-16 LE BOM 文件,
|
||||
/// 每个 ASCII 字符高字节为 0x00(NUL)。旧 read_file 用 read_to_string 直接读 →
|
||||
/// (a) read_to_string 对含 NUL 字节内容硬失败 InvalidData;或 (b) 即便侥幸读到,
|
||||
/// 后续 is_binary / patch_file 的 `\0` 检测把 UTF-16 文本误判二进制拒读 →
|
||||
/// run_command(Out-File) → 文件 → read_file 链路在 Windows 断裂。
|
||||
///
|
||||
/// 本 helper 替代 read_to_string 的纯字节→字符串转换:
|
||||
/// 1. UTF-16 LE BOM(FF FE):strip BOM 后 u16::from_le_bytes + String::from_utf16 解码;
|
||||
/// 2. UTF-16 BE BOM(FE FF):同上 from_be_bytes;
|
||||
/// 3. 无 BOM:走 String::from_utf8(等价原 read_to_string 的 UTF-8 校验)。
|
||||
/// 注:UTF-8 BOM(EF BB BF)是合法 UTF-8 序列,read_to_string 原本就能读(内容首字符多 U+FEFF),
|
||||
/// 不在本 helper 误判路径内,无需特判。
|
||||
///
|
||||
/// 解码失败(真二进制/非 UTF-8 非 BOM 内容)返 io::Error(InvalidData),与 read_to_string
|
||||
/// 同语义——调用方保留原 `if e.kind() == InvalidData { 返 binary 标记 }` 分支不变,
|
||||
/// 真二进制(图片/编译产物)仍被拦截,UTF-16 文本因 BOM 优先识别不再进 \x00 误判路径。
|
||||
///
|
||||
/// 奇数字节 UTF-16(尾字节落单):drop 最后一个残字节后解码(对齐 PowerShell 偶发写半字符场景,
|
||||
/// 不 bail 让多数可读内容通过)。Lone surrogate 交 String::from_utf16 lossy 兜底不 panic。
|
||||
pub(crate) fn decode_bytes_to_string(bytes: &[u8]) -> std::io::Result<String> {
|
||||
// UTF-16 LE BOM: FF FE
|
||||
if bytes.len() >= 2 && bytes[0] == 0xFF && bytes[1] == 0xFE {
|
||||
let body = &bytes[2..];
|
||||
let units: Vec<u16> = body
|
||||
.chunks_exact(2)
|
||||
.map(|c| u16::from_le_bytes([c[0], c[1]]))
|
||||
.collect();
|
||||
// from_utf16 遇 lone surrogate 返 Err(此处用 lossy 兜底保内容可读,不阻断)
|
||||
return Ok(String::from_utf16_lossy(&units));
|
||||
}
|
||||
// UTF-16 BE BOM: FE FF
|
||||
if bytes.len() >= 2 && bytes[0] == 0xFE && bytes[1] == 0xFF {
|
||||
let body = &bytes[2..];
|
||||
let units: Vec<u16> = body
|
||||
.chunks_exact(2)
|
||||
.map(|c| u16::from_be_bytes([c[0], c[1]]))
|
||||
.collect();
|
||||
return Ok(String::from_utf16_lossy(&units));
|
||||
}
|
||||
// 无 BOM:走 UTF-8 校验(等价 read_to_string,失败 InvalidData → 调用方判二进制)
|
||||
String::from_utf8(bytes.to_vec())
|
||||
.map_err(|_| std::io::Error::new(std::io::ErrorKind::InvalidData, "非 UTF-8 / 非 UTF-16 BOM 文本"))
|
||||
}
|
||||
|
||||
/// 阶段4(容错/恢复,开关 `df-ai-approval-retry`):跨盘/跨卷文件移动统一降级 helper。
|
||||
///
|
||||
/// 背景:Windows 跨盘符(C→E)或跨卷时 `tokio::fs::rename` 报 `os error 17`
|
||||
@@ -448,16 +494,26 @@ pub(crate) async fn bind_dir_to_project(
|
||||
/// F-260619-03 Phase A: 新增 `allowed_dirs: &Arc<RwLock<AllowedDirs>>` 形参,
|
||||
/// 文件工具闭包 clone 进闭包,在 resolve_workspace_path_with_allowed 校验动态白名单。
|
||||
/// data/http 工具不涉及文件路径,不传白名单。
|
||||
///
|
||||
/// get_app_config(2026-08-02):新增 `get_app_config_ctx: GetAppConfigCtx` 形参,
|
||||
/// 该工具查 DevFlow 自身当前 AI 配置(provider/agent/settings),需读 AppState 内存原子量
|
||||
/// (agent_max_iterations 等)+ db。因本函数在 AppState::init 内、AppState 构建中被调,
|
||||
/// 无法传 &AppState(自引用),故把所需 Arc 句柄打包成 GetAppConfigCtx 传入(state.rs
|
||||
/// init 先建这些 Arc 再 build registry 再组装 state,顺序调整)。
|
||||
pub fn build_ai_tool_registry(
|
||||
db: &Arc<Database>,
|
||||
allowed_dirs: &Arc<RwLock<AllowedDirs>>,
|
||||
data_dir: PathBuf,
|
||||
get_app_config_ctx: super::get_app_config::GetAppConfigCtx,
|
||||
) -> AiToolRegistry {
|
||||
let mut registry = AiToolRegistry::new();
|
||||
register_data_tools(&mut registry, db);
|
||||
register_file_tools(&mut registry, allowed_dirs, data_dir);
|
||||
register_http_tools(&mut registry);
|
||||
register_http_tools(&mut registry, allowed_dirs);
|
||||
register_fetch_url_tool(&mut registry);
|
||||
register_fetch_search_tool(&mut registry);
|
||||
register_generate_image_tool(&mut registry, db, allowed_dirs);
|
||||
register_get_app_config_tool(&mut registry, get_app_config_ctx);
|
||||
registry
|
||||
}
|
||||
|
||||
@@ -473,8 +529,11 @@ pub fn build_ai_tool_registry(
|
||||
/// async move 块(逻辑零变更),仅闭包包装改由 `declare_tool!` 宏生成。本函数改 thin 委托
|
||||
/// super::tools::http::register(...)。基线测试 test_build_ai_tool_registry_baseline_tool_count
|
||||
/// 仍断言 48 总量 + 工具名集合稳定。
|
||||
fn register_http_tools(registry: &mut AiToolRegistry) {
|
||||
super::tools::http::register(registry);
|
||||
///
|
||||
/// 2026-08-01 output_file 参数:响应可落盘,路径走文件路径校验,故透传 allowed_dirs 给
|
||||
/// tools::http::register(由其闭包捕获,handler 内 read lock clone 传 execute_http_request)。
|
||||
fn register_http_tools(registry: &mut AiToolRegistry, allowed_dirs: &Arc<RwLock<AllowedDirs>>) {
|
||||
super::tools::http::register(registry, allowed_dirs);
|
||||
}
|
||||
|
||||
/// fetch_url AI 工具注册(1 个:URL → markdown 文档嗅探)— 只读 GET 网页文档。
|
||||
@@ -490,6 +549,63 @@ fn register_fetch_url_tool(registry: &mut AiToolRegistry) {
|
||||
super::tools::fetch_url::register(registry);
|
||||
}
|
||||
|
||||
/// fetch_search AI 工具注册(1 个:搜索引擎查询)— DuckDuckGo HTML 端点免 key 搜索。
|
||||
/// 不持 db,纯 reqwest GET + HTML 字符串扫描解析。SSRF 防护复用 commands/ai/http.rs
|
||||
/// (validate_url / resolve_and_check_host / build_client / execute_with_redirects)。
|
||||
///
|
||||
/// 风险:Low(只读 GET 搜索,无副作用,与 fetch_url 同级)。与 fetch_url 分工:fetch_url 输出
|
||||
/// 已知 URL 的 markdown 正文,fetch_search 输出未知关键词的候选 URL 列表(标题+摘要),
|
||||
/// 供 LLM 挑选入口再 fetch_url 深读。补「fetch_url 只抓已知 URL,无搜索能力」缺口。
|
||||
///
|
||||
/// HTML 解析:手写字符串扫描(对齐 fetch_url::extract_title 风格),不引 scraper 重依赖
|
||||
/// (html5ever+selectors+cssparser 编译产物大,DDG 单一来源不值)。基线测试守护总量 + 工具名集合。
|
||||
fn register_fetch_search_tool(registry: &mut AiToolRegistry) {
|
||||
super::tools::fetch_search::register(registry);
|
||||
}
|
||||
|
||||
/// generate_image AI 工具注册(1 个:调 provider 图像生成端点 + 下载落地)。
|
||||
///
|
||||
/// 工具职责:调 provider 的 OpenAI 兼容 `/v1/images/generations` 端点生成图片(默认 SenseNova
|
||||
/// U1 Fast,也支持 DALL-E 等),自动下载到工作区 output_path。图像生成模型非 chat,走独立
|
||||
/// 端点非 chat completions,故不能经主对话链路调用,必须用本工具显式触发。
|
||||
///
|
||||
/// 涉网络(provider POST + 图片 URL 下载)+ 付费 API(按张计费)+ 写文件(落盘 output_path),
|
||||
/// 与 download_file 同源(均持 allowed_dirs 做 output_path 白名单校验 + 默认路径锚定)。
|
||||
/// risk=High(付费 + 写磁盘 + 外发)。基线测试 test_build_ai_tool_registry_baseline_tool_count
|
||||
/// 守护总量 + 工具名集合稳定。
|
||||
///
|
||||
/// handler 在 commands/ai/generate_image.rs,声明式注册在 tools/generate_image.rs。
|
||||
/// declare_tool! 单捕获限制:tools 层把 (db, allowed_dirs) 包进 tuple Arc 在闭包内解构。
|
||||
fn register_generate_image_tool(
|
||||
registry: &mut AiToolRegistry,
|
||||
db: &Arc<Database>,
|
||||
allowed_dirs: &Arc<RwLock<AllowedDirs>>,
|
||||
) {
|
||||
super::tools::generate_image::register(registry, db, allowed_dirs);
|
||||
}
|
||||
|
||||
/// get_app_config AI 工具注册(1 个:查 DevFlow 自身当前 AI 配置,只读 + 脱敏)—
|
||||
/// 返 {default_provider, providers, agent, app_settings} JSON。
|
||||
///
|
||||
/// 治症状:AI 想看「当前 provider/model/agent 设置」时,因没原生查配置工具,只能 run_command
|
||||
/// 执行 PowerShell 内联 node/python 脚本查 db,引号三重嵌套必失败。本工具一调即得,根治绕行。
|
||||
///
|
||||
/// 风险:Low(纯只读 list/load,无写副作用)。安全:api_key 经 mask_api_key 脱敏
|
||||
/// (前 4 + •••• + 后 4),绝不返明文;app_settings 走白名单不返敏感 KV。
|
||||
///
|
||||
/// handler 在 commands/ai/get_app_config.rs,声明式注册在 tools/get_app_config.rs。
|
||||
/// declare_tool! 单捕获:把 db + 3 个 agent 配置 Arc + LlmConcurrency 包进 GetAppConfigCtx
|
||||
/// struct(Clone 廉价,全 Arc 字段),闭包内 &ctx 转调 handler。
|
||||
///
|
||||
/// ctx 字段与 AppState 同名字段共享同一底层原子量(经 ai_set_concurrency_config /
|
||||
/// ai_set_agent_max_iterations 等 IPC 热改后,handler 读到的永远是当前生效值)。
|
||||
fn register_get_app_config_tool(
|
||||
registry: &mut AiToolRegistry,
|
||||
ctx: super::get_app_config::GetAppConfigCtx,
|
||||
) {
|
||||
super::tools::get_app_config::register(registry, ctx);
|
||||
}
|
||||
|
||||
/// 数据层 AI 工具注册(25 个持 db 的 CRUD/状态机/工作流/知识图谱工具)——从 build_ai_tool_registry 抽出。
|
||||
///
|
||||
/// 工具闭包捕获 `db: &Arc<Database>` Arc 重建 Repo(列表/创建/更新/删除/状态推进/工作流/任务关联)。
|
||||
@@ -639,6 +755,10 @@ fn register_file_tools(
|
||||
data_dir: PathBuf,
|
||||
) {
|
||||
super::tools::file::register(registry, allowed_dirs, data_dir);
|
||||
// download_file:跨平台 URL→文件流式下载(reqwest bytes_stream + tokio::fs)。
|
||||
// 写文件需路径授权(与 write_file 同源),故在 register_file_tools 注册(已有 allowed_dirs)。
|
||||
// SSRF 复用 commands/ai/http.rs,大小上限 200MB + 原子写(tmp→rename)。
|
||||
super::tools::download_file::register(registry, allowed_dirs);
|
||||
}
|
||||
|
||||
/// 探测可执行文件路径(which/where 风格),8s 超时兜底。
|
||||
@@ -1122,6 +1242,28 @@ pub(crate) fn search_files_recursive<'a>(
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
// get_app_config baseline 测试辅助:构造 GetAppConfigCtx(与 AppState init 同款默认值,
|
||||
// 仅用于 build_ai_tool_registry 注册阶段定义完整性断言,handler 不实际执行故无需真实配置)。
|
||||
use std::sync::atomic::{AtomicU64, AtomicUsize};
|
||||
use crate::state::LlmConcurrency;
|
||||
|
||||
/// 测试用 GetAppConfigCtx 构造器(默认值对齐 AppState::init:iter=10/retries=3/timeout=15/concurrency 3,2)。
|
||||
///
|
||||
/// build_ai_tool_registry 第 4 参,5 处测试调用共用,避免每处内联 5 行重复。
|
||||
/// Arc 句柄独立于 AppState(测试无 AppState),仅满足注册期类型签名,handler 不执行。
|
||||
fn make_test_get_app_config_ctx(db: &Arc<Database>) -> super::get_app_config::GetAppConfigCtx {
|
||||
super::get_app_config::GetAppConfigCtx {
|
||||
db: db.clone(),
|
||||
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,
|
||||
)),
|
||||
approval_timeout_minutes: Arc::new(AtomicU64::new(15)),
|
||||
llm_concurrency: LlmConcurrency::new(3, 2),
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 工具注册基线测试(SMELL-P0-2 拆分防护)
|
||||
@@ -1131,7 +1273,7 @@ mod tests {
|
||||
// 任一层漏移 register 调用,此测试立即红。工具名集合也断言,防 rename 致 LLM tool 突变。
|
||||
// ============================================================
|
||||
|
||||
/// build_ai_tool_registry 应注册恰好 51 个工具(36 data + 13 file + 1 http + 1 fetch_url),且工具名集合稳定。
|
||||
/// build_ai_tool_registry 应注册恰好 55 个工具(36 data + 14 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config),且工具名集合稳定。
|
||||
///
|
||||
/// 用 in-memory SQLite(Database::open_in_memory 自跑迁移),构造零外部依赖的 db,
|
||||
// 不实际执行任何 handler——仅断言注册阶段的定义完整性,故无需真实数据。
|
||||
@@ -1142,7 +1284,7 @@ mod tests {
|
||||
// F-260619-03 Phase A: build_ai_tool_registry 新增 allowed_dirs 形参,
|
||||
// 测试用 default_with_root(仅 workspace_root),零回归(白名单含 workspace_root)。
|
||||
let allowed_dirs = Arc::new(RwLock::new(AllowedDirs::default_with_root()));
|
||||
let registry = build_ai_tool_registry(&db, &allowed_dirs, PathBuf::from(""));
|
||||
let registry = build_ai_tool_registry(&db, &allowed_dirs, PathBuf::from(""), make_test_get_app_config_ctx(&db));
|
||||
|
||||
// 总量基线:41(27 data + 13 file + 1 http)。拆分前后必须一致。
|
||||
// F-260621: file 层 10→11(新增 grep 跨文件内容搜索工具)。
|
||||
@@ -1168,15 +1310,31 @@ mod tests {
|
||||
// fetch_url(2026-08-01): http 层 1→2(新增 URL→markdown 文档嗅探,只读 GET,
|
||||
// htmd HTML→markdown + 去噪音 + 截断,SSRF 复用 http.rs)。
|
||||
// 51 = 36 data + 13 file + 1 http + 1 fetch_url。
|
||||
// download_file(2026-08-01): file 层 13→14(新增 URL→文件跨平台流式下载,
|
||||
// reqwest bytes_stream + tokio::fs,替代 curl/wget alias 陷阱。写文件需 allowed_dirs
|
||||
// 故注册在 register_file_tools;SSRF 复用 http.rs + 路径校验复用 validate_path)。
|
||||
// 52 = 36 data + 14 file + 1 http + 1 fetch_url。
|
||||
// fetch_search(2026-08-02): fetch_search 层 1→2(新增搜索引擎查询,DuckDuckGo HTML 免 key,
|
||||
// GET + HTML 字符串扫描解析结果列表,补 fetch_url 只抓已知 URL 缺口。SSRF 复用 http.rs)。
|
||||
// 53 = 36 data + 14 file + 1 http + 1 fetch_url + 1 fetch_search。
|
||||
// generate_image(2026-08-02): generate_image 层 1→1(新增调 provider 图像生成端点 +
|
||||
// 下载落地,默认 sensenova-u1-fast,也支持 DALL-E。SSRF 复用 http.rs + 路径校验复用
|
||||
// resolve_workspace_path_with_allowed,declare_tool! 单捕获绕法用 tuple Arc)。
|
||||
// 54 = 36 data + 14 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image。
|
||||
// get_app_config(2026-08-02): get_app_config 层 1→1(新增查 DevFlow 自身当前 AI 配置,
|
||||
// 只读 + api_key 脱敏,治 AI 查配置绕 run_command PowerShell 内联脚本引号嵌套失败。
|
||||
// build_ai_tool_registry 加第 4 参 GetAppConfigCtx 绕 AppState 自引用,declare_tool!
|
||||
// 单捕获用 struct ctx 包 db + 3 agent Arc + LlmConcurrency,共享 AppState 同名句柄)。
|
||||
// 55 = 36 data + 14 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config。
|
||||
assert_eq!(
|
||||
registry.len(),
|
||||
51,
|
||||
"工具总数应为 51(36 data + 13 file + 1 http + 1 fetch_url),实际 {}", registry.len()
|
||||
55,
|
||||
"工具总数应为 55(36 data + 14 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config),实际 {}", registry.len()
|
||||
);
|
||||
|
||||
// 工具名集合基线:防 rename / 漏注册 / 误删除。
|
||||
// data 层 36 个(持 db):CRUD/状态机/工作流/知识图谱任务关联 + 项目事件流 + 基础设施配置 + git 工具
|
||||
// file 层 13 个(不持 db):命令/读/列/写/改/元/追加/删/移/搜/grep/环境探测/符号解析
|
||||
// file 层 14 个(不持 db):命令/读/列/写/改/元/追加/删/移/搜/grep/环境探测/符号解析/下载
|
||||
// http 层 1 个(不持 db):http_request
|
||||
let mut expected: Vec<&str> = vec![
|
||||
// ── data 层 (36) ──
|
||||
@@ -1200,17 +1358,27 @@ mod tests {
|
||||
"git_status", "git_diff", "git_log",
|
||||
// Git 写工具
|
||||
"git_commit", "git_branch", "git_merge",
|
||||
// ── file 层 (13) ──(run_command 注册顺序已移至末位降低 LLM 偏好,
|
||||
// ── file 层 (14) ──(run_command 注册顺序已移至末位降低 LLM 偏好,
|
||||
// 集合断言经 sort 后与顺序无关,仅守护工具名不漂移。grep 新增 F-260621;
|
||||
// detect_environment 新增 L1 环境感知 设计 §2.1;read_symbol 新增 AST 代码智能)
|
||||
// detect_environment 新增 L1 环境感知 设计 §2.1;read_symbol 新增 AST 代码智能;
|
||||
// download_file 新增跨平台 URL→文件流式下载)
|
||||
"read_file", "read_symbol", "list_directory", "write_file",
|
||||
"patch_file", "file_info", "append_file",
|
||||
"delete_file", "rename_file", "search_files", "run_command",
|
||||
"grep", "detect_environment",
|
||||
// download_file(URL→文件流式下载,写文件需 allowed_dirs,注册在 register_file_tools)
|
||||
"download_file",
|
||||
// ── http 层 (1) ──
|
||||
"http_request",
|
||||
// ── fetch_url 层 (1) ──(URL → markdown 文档嗅探,只读 GET,与 http_request 分工)
|
||||
"fetch_url",
|
||||
// ── fetch_search 层 (1) ──(搜索引擎查询 DDG HTML 免 key,只读 GET,补 fetch_url 缺口)
|
||||
"fetch_search",
|
||||
// ── generate_image 层 (1) ──(调 provider 图像生成端点 + 下载落地,默认 sensenova-u1-fast)
|
||||
"generate_image",
|
||||
// ── get_app_config 层 (1) ──(查 DevFlow 自身当前 AI 配置,只读 + api_key 脱敏,
|
||||
// 治 AI 查配置绕 run_command PowerShell 内联脚本引号嵌套失败)
|
||||
"get_app_config",
|
||||
];
|
||||
expected.sort_unstable();
|
||||
|
||||
@@ -1412,7 +1580,7 @@ mod tests {
|
||||
|
||||
let db = Database::open_in_memory().await.expect("in-memory db 初始化失败");
|
||||
let db = Arc::new(db);
|
||||
let registry = build_ai_tool_registry(&db, &allowed_dirs, PathBuf::from(""));
|
||||
let registry = build_ai_tool_registry(&db, &allowed_dirs, PathBuf::from(""), make_test_get_app_config_ctx(&db));
|
||||
let canon_file = file.canonicalize().unwrap().to_string_lossy().to_string();
|
||||
let args = serde_json::json!({ "path": canon_file, "limit": 15 });
|
||||
let res = registry.execute("read_file", args).await.expect("read_file 执行失败");
|
||||
@@ -1439,7 +1607,7 @@ mod tests {
|
||||
|
||||
let db = Database::open_in_memory().await.expect("in-memory db 初始化失败");
|
||||
let db = Arc::new(db);
|
||||
let registry = build_ai_tool_registry(&db, &allowed_dirs, PathBuf::from(""));
|
||||
let registry = build_ai_tool_registry(&db, &allowed_dirs, PathBuf::from(""), make_test_get_app_config_ctx(&db));
|
||||
let canon_file = file.canonicalize().unwrap().to_string_lossy().to_string();
|
||||
let args = serde_json::json!({ "path": canon_file });
|
||||
let res = registry.execute("read_file", args).await.expect("read_file 执行失败");
|
||||
@@ -1450,6 +1618,114 @@ mod tests {
|
||||
fs::remove_dir_all(&tmp).ok();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// P1 修复:UTF-16 LE/BE BOM 文件解码(PowerShell Out-File/> 产 UTF-16 LE,
|
||||
// 旧 read_to_string 因 ASCII 高字节 0x00 判 InvalidData → 误判二进制拒读)
|
||||
// decode_bytes_to_string 纯函数单测 + read_file 端到端 + 真 binary 仍拒读
|
||||
// ============================================================
|
||||
|
||||
/// decode_bytes_to_string: UTF-16 LE BOM(FF FE) → 可读 UTF-8
|
||||
#[test]
|
||||
fn test_decode_bytes_utf16_le_bom() {
|
||||
// "hello\n" UTF-16 LE 编码
|
||||
let text = "hello\n";
|
||||
let mut bytes: Vec<u8> = vec![0xFF, 0xFE]; // LE BOM
|
||||
for u in text.encode_utf16() {
|
||||
bytes.extend_from_slice(&u.to_le_bytes());
|
||||
}
|
||||
let s = decode_bytes_to_string(&bytes).expect("UTF-16 LE BOM 应解码成功");
|
||||
assert_eq!(s, text, "UTF-16 LE BOM 文件应解码为原文本");
|
||||
}
|
||||
|
||||
/// decode_bytes_to_string: UTF-16 BE BOM(FE FF) → 可读 UTF-8
|
||||
#[test]
|
||||
fn test_decode_bytes_utf16_be_bom() {
|
||||
// 中文 + ASCII,UTF-16 BE 编码(验证非 ASCII 码点 + 大端序)
|
||||
let text = "中文 ascii\n";
|
||||
let mut bytes: Vec<u8> = vec![0xFE, 0xFF]; // BE BOM
|
||||
for u in text.encode_utf16() {
|
||||
bytes.extend_from_slice(&u.to_be_bytes());
|
||||
}
|
||||
let s = decode_bytes_to_string(&bytes).expect("UTF-16 BE BOM 应解码成功");
|
||||
assert_eq!(s, text, "UTF-16 BE BOM 文件应解码为原文本(含中文码点)");
|
||||
}
|
||||
|
||||
/// decode_bytes_to_string: 无 BOM 真 binary(密集 \x00 非 BOM 前缀) → InvalidData
|
||||
#[test]
|
||||
fn test_decode_bytes_real_binary_rejected() {
|
||||
// 真二进制:图片/编译产物常见,前两字节非 BOM 模式且含密集 NUL
|
||||
let bin: Vec<u8> = vec![0x89, 0x50, 0x4E, 0x47, 0x00, 0x0D, 0x00, 0x0A]; // PNG 头片段
|
||||
let err = decode_bytes_to_string(&bin).expect_err("真二进制应解码失败");
|
||||
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData, "真二进制返 InvalidData");
|
||||
}
|
||||
|
||||
/// decode_bytes_to_string: 普通 UTF-8 无 BOM → 透传(回归保护)
|
||||
#[test]
|
||||
fn test_decode_bytes_plain_utf8_passthrough() {
|
||||
let bytes = "普通 UTF-8 文本\n".as_bytes();
|
||||
let s = decode_bytes_to_string(bytes).expect("UTF-8 无 BOM 应直接通过");
|
||||
assert_eq!(s, "普通 UTF-8 文本\n");
|
||||
}
|
||||
|
||||
/// read_file 端到端:UTF-16 LE BOM 文件经 registry.execute 读出正常 content + binary=false
|
||||
#[tokio::test]
|
||||
async fn test_read_file_utf16_le_bom_decodes() {
|
||||
let tmp = std::env::temp_dir().join(format!("df_readfile_utf16le_{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&tmp);
|
||||
fs::create_dir_all(&tmp).unwrap();
|
||||
// 造 UTF-16 LE BOM 文件(模拟 PowerShell Out-File 产物)
|
||||
let text = "line1\nline2\nPowerShell output\n";
|
||||
let mut bytes: Vec<u8> = vec![0xFF, 0xFE];
|
||||
for u in text.encode_utf16() {
|
||||
bytes.extend_from_slice(&u.to_le_bytes());
|
||||
}
|
||||
let file = tmp.join("ps_out.txt");
|
||||
fs::write(&file, &bytes).unwrap();
|
||||
|
||||
let mut persistent = std::collections::HashSet::new();
|
||||
persistent.insert(tmp.clone());
|
||||
let allowed_dirs = Arc::new(RwLock::new(AllowedDirs { persistent, session: Default::default(), once: Default::default() }));
|
||||
let db = Arc::new(Database::open_in_memory().await.expect("in-memory db 初始化失败"));
|
||||
let registry = build_ai_tool_registry(&db, &allowed_dirs, PathBuf::from(""), make_test_get_app_config_ctx(&db));
|
||||
|
||||
let canon_file = file.canonicalize().unwrap().to_string_lossy().to_string();
|
||||
let args = serde_json::json!({ "path": canon_file });
|
||||
let res = registry.execute("read_file", args).await.expect("read_file 执行失败");
|
||||
// 关键断言:不再误判二进制,返正常文本内容
|
||||
assert_eq!(res["binary"], serde_json::Value::Null, "UTF-16 LE BOM 不应判二进制");
|
||||
let content = res["content"].as_str().expect("应返回 content 字段");
|
||||
assert!(content.contains("line1"), "解码内容应含 line1,实际: {}", content);
|
||||
assert!(content.contains("PowerShell output"), "解码内容应含原文,实际: {}", content);
|
||||
|
||||
fs::remove_dir_all(&tmp).ok();
|
||||
}
|
||||
|
||||
/// read_file 端到端:真 binary(密集 \x00 非 BOM)仍返 binary:true(拦截不破)
|
||||
#[tokio::test]
|
||||
async fn test_read_file_real_binary_still_rejected() {
|
||||
let tmp = std::env::temp_dir().join(format!("df_readfile_bin_{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&tmp);
|
||||
fs::create_dir_all(&tmp).unwrap();
|
||||
// PNG 签名 + 密集 NUL(真二进制,前两字节 89 50 非 BOM)
|
||||
let bin: Vec<u8> = vec![0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0xFF, 0xFE];
|
||||
let file = tmp.join("image.png");
|
||||
fs::write(&file, &bin).unwrap();
|
||||
|
||||
let mut persistent = std::collections::HashSet::new();
|
||||
persistent.insert(tmp.clone());
|
||||
let allowed_dirs = Arc::new(RwLock::new(AllowedDirs { persistent, session: Default::default(), once: Default::default() }));
|
||||
let db = Arc::new(Database::open_in_memory().await.expect("in-memory db 初始化失败"));
|
||||
let registry = build_ai_tool_registry(&db, &allowed_dirs, PathBuf::from(""), make_test_get_app_config_ctx(&db));
|
||||
|
||||
let canon_file = file.canonicalize().unwrap().to_string_lossy().to_string();
|
||||
let args = serde_json::json!({ "path": canon_file });
|
||||
let res = registry.execute("read_file", args).await.expect("read_file 执行失败");
|
||||
assert_eq!(res["binary"], true, "真二进制(非 BOM 前缀)应仍被拦截返 binary:true");
|
||||
assert_eq!(res["content"], serde_json::Value::Null, "真二进制 content 应为 null");
|
||||
|
||||
fs::remove_dir_all(&tmp).ok();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// patch_file 三模式共用底层测试(F-260617-01)
|
||||
// 纯函数 apply_line_range / resolve_anchor_to_lines,不依赖 fs/async/锁。
|
||||
@@ -1921,7 +2197,7 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
let allowed_dirs = Arc::new(RwLock::new(AllowedDirs::default_with_root()));
|
||||
let registry = build_ai_tool_registry(&db, &allowed_dirs, PathBuf::from(""));
|
||||
let registry = build_ai_tool_registry(&db, &allowed_dirs, PathBuf::from(""), make_test_get_app_config_ctx(&db));
|
||||
(db, registry)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user