//! download_file AI 工具 — URL → 文件(跨平台流式下载,无 shell/PowerShell 陷阱) //! //! 设计目标:让 LLM 把远程文件(URL)落到本地磁盘,替代 `run_command` 里的 //! `curl -o` / `wget` / `Invoke-WebRequest`。后者在 Windows 上踩 alias、编码、 //! 跨平台陷阱(PowerShell alias 表不同、GBK 解码、Invoke-WebRequest 不在 PS Core 外可用、 //! curl 在 Win 是 alias = Invoke-WebRequest 而非真 curl)。本工具纯 reqwest + tokio::fs, //! 跨平台一致,无 shell spawn。 //! //! ## 与 http_request / fetch_url 的分工 //! //! - `http_request`:结构化 API 调用,返回 body(截断 50KB),不落盘。 //! - `fetch_url`:网页 → markdown 文档嗅探,返回 markdown,不落盘。 //! - `download_file`:**URL → 落盘文件**(二进制/大文件友好,流式写,不进 prompt)。 //! //! ## 安全 //! //! 1. **SSRF 防护**:复用 `commands::ai::http` 的 `validate_url` / `resolve_and_check_host` / //! `build_client` / `execute_with_redirects`(协议白名单 + 私网 IP 黑名单 + DNS resolve 后 //! 校验防 rebinding + 重定向 ≤3 跳每跳重校验)。只发 GET,无 body。 //! 2. **路径校验**:复用 `tool_registry::validate_path` + `resolve_workspace_path_with_allowed` //! (URL 解码防 %2e%2e 遍历 + 词法层越界兜底 + canonicalize symlink 逃逸拦截 + 授权目录白名单)。 //! 与 write_file 同款,output_path 必须落在授权目录内。 //! 3. **大小上限**:默认 200MB(可调 const),流式边下边累计,超限立即中止 + 删半成品,防磁盘炸。 //! 4. **原子写**:tmp → rename(同目录,不跨卷),写一半崩溃不留半成品文件(对齐 write_file FR-S7)。 //! //! ## 风险:High //! //! 下载文件有副作用(写磁盘 + 触发外发网络 + 可能拿到可执行/敏感内容),统一 High 须人工批准, //! 与 write_file(Medium)区别在于:download 的来源是外部不可信网络,不可预知内容。 use std::collections::HashMap; use std::time::{Duration, Instant}; use serde_json::{json, Value}; use tokio::io::AsyncWriteExt; use crate::commands::ai::http::{ build_client, execute_with_redirects, resolve_and_check_host, validate_url, MAX_REDIRECTS, }; use crate::commands::ai::tool_registry::{ DEFAULT_RUN_COMMAND_TIMEOUT_SECS, MAX_RUN_COMMAND_TIMEOUT_SECS, }; /// 下载文件大小硬上限(字节)。200MB —— 防超大文件(ISO/镜像/视频)撑爆磁盘。 /// 流式边下边累计,超限立即中止 + 删半成品。需更大文件走专门工具/分片,不在此。 const MAX_DOWNLOAD_BYTES: u64 = 200 * 1024 * 1024; /// download_file 工具 handler 入口(供 tools/download_file.rs register 调用)。 /// /// 参数: /// - url: 必填,http/https(SSRF 防护拒私网/localhost) /// - output_path: 必填,落盘路径(必须落在授权目录内,复用 write_file 同款路径校验) /// - timeout_secs: 可选,默认 DEFAULT_RUN_COMMAND_TIMEOUT_SECS(60),clamp ≤MAX(600) /// /// 返回 {path, bytes_written, status, url} /// /// 注:handler 不直接拿 allowed_dirs(tools/download_file.rs 闭包捕获并在调用前解析路径)。 /// 本函数接收「已解析的绝对路径字符串」(resolved_path),路径校验在调用方完成(闭包持有 allowed_dirs, /// 解析后传字符串进来,与 write_file 模式一致 —— resolve_workspace_path_with_allowed 需 allowed_dirs /// 快照,故解析留在闭包内,本函数专注网络 + 落盘)。 pub(crate) async fn execute_download_file( args: Value, resolved_output_path: String, ) -> anyhow::Result { // ── 参数解析 ── let url_raw = args.get("url").and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("缺少 url 参数"))? .trim() .to_string(); if url_raw.is_empty() { anyhow::bail!("url 不能为空"); } if resolved_output_path.trim().is_empty() { anyhow::bail!("output_path 不能为空"); } let timeout_secs = args.get("timeout_secs").and_then(|v| v.as_u64()) .unwrap_or(DEFAULT_RUN_COMMAND_TIMEOUT_SECS) .clamp(1, MAX_RUN_COMMAND_TIMEOUT_SECS); // ── SSRF 校验(三层:词法 URL → DNS resolve IP → 每跳重定向重复) ── let (_scheme, host, port) = validate_url(&url_raw)?; resolve_and_check_host(&host, port).await?; // ── 构建限制 client + 执行 GET(手动重定向循环复用 http.rs) ── let client = build_client(Duration::from_secs(timeout_secs))?; let started = Instant::now(); let resp = execute_with_redirects( &client, reqwest::Method::GET, url_raw.clone(), &HashMap::new(), // download_file 不带自定义头(纯 GET 文件) &None, // 无 body MAX_REDIRECTS, ) .await?; let status = resp.status().as_u16(); let final_url = resp.url().to_string(); if !resp.status().is_success() { anyhow::bail!( "下载失败:HTTP {} {}({})", status, resp.status().canonical_reason().unwrap_or(""), final_url ); } // ── 流式写文件(避免大文件 OOM) ── // response.bytes_stream() 需 reqwest "stream" feature(src-tauri/Cargo.toml 已声明)。 // 边下边写 + 累计字节,超 MAX_DOWNLOAD_BYTES 立即中止 + 删半成品。 let target = std::path::Path::new(&resolved_output_path); // 父目录必须存在且在授权目录内(防 path=授权根时 parent 越界 create_dir_all)。 // 路径白名单校验已在调用方(resolve_workspace_path_with_allowed)完成,这里只补 parent 授权 + // create_dir_all(对齐 write_file FR-S8)。parent 授权复检由调用方解析时已覆盖(resolved 已过白名单), // create_dir_all 仅针对尚不存在的 parent 目录创建。 if let Some(parent) = target.parent() { tokio::fs::create_dir_all(parent).await .map_err(|e| anyhow::anyhow!("创建目录失败: {}", e))?; } // 原子写:tmp → rename(同目录,不跨卷)。写一半崩溃/超限中止均删 tmp,不留半成品。 let tmp = format!("{}.tmp-download", resolved_output_path); let mut file = tokio::fs::File::create(&tmp).await .map_err(|e| anyhow::anyhow!("创建临时文件失败 ({}): {}", tmp, e))?; let mut stream = resp.bytes_stream(); let mut written: u64 = 0; let mut exceeded = false; use futures::StreamExt; while let Some(chunk) = stream.next().await { let chunk = chunk.map_err(|e| anyhow::anyhow!("读取响应流失败: {}", e))?; written += chunk.len() as u64; if written > MAX_DOWNLOAD_BYTES { exceeded = true; break; } file.write_all(&chunk).await .map_err(|e| anyhow::anyhow!("写入文件失败: {}", e))?; } // flush + 关闭(file drop 前 flush,确保数据落盘再 rename) file.flush().await .map_err(|e| anyhow::anyhow!("flush 文件失败: {}", e))?; drop(file); if exceeded { let _ = tokio::fs::remove_file(&tmp).await; // 删半成品 anyhow::bail!( "下载超过 {} 字节上限,已中止并清理临时文件", MAX_DOWNLOAD_BYTES ); } // tmp → 目标(原子替换,同目录不跨卷)。覆盖既有文件时旧文件被替换。 if let Err(e) = tokio::fs::rename(&tmp, &resolved_output_path).await { let _ = tokio::fs::remove_file(&tmp).await; return Err(anyhow::anyhow!("原子替换失败: {}", e)); } let elapsed_ms = started.elapsed().as_millis() as u64; Ok(json!({ "path": resolved_output_path, "bytes_written": written, "status": status, "url": final_url, "elapsed_ms": elapsed_ms, })) } // ============================================================ // 单元测试 // // 覆盖: // ① SSRF 拦截私网(validate_url 拒 127.0.0.1/10.x/169.254):走 handler 入口早期拒绝, // 断言错误信息含关键词(对齐 http.rs / fetch_url.rs 测试策略)。 // ② 路径越界拒:validate_path 纯函数,断言 .. / 敏感目录被拒。 // 真实下载集成走 #[ignore](CI 无网时跳过,本地手跑)。 // ============================================================ #[cfg(test)] mod tests { use super::*; use crate::commands::ai::tool_registry::validate_path; // ── validate_path 纯函数:路径越界 / 敏感目录拒(可单测,零依赖) ── #[test] fn test_validate_path_rejects_traversal() { // .. 段被拒(分段归一化,不误伤 my..file) assert!(validate_path("../etc/passwd").is_err()); assert!(validate_path("foo/../../bar").is_err()); assert!(validate_path("a/..").is_err()); // URL 编码绕过被解码后拦截(%2e%2e = ..) assert!(validate_path("%2e%2e/secret").is_err()); assert!(validate_path("%2e%2e%2fetc").is_err()); } #[test] fn test_validate_path_accepts_normal_relative() { // 正常相对路径通过(my..file 不误伤) assert!(validate_path("downloads/file.zip").is_ok()); assert!(validate_path("my..file.txt").is_ok()); assert!(validate_path("a/b/c").is_ok()); } #[test] fn test_validate_path_rejects_sensitive_system_dirs() { // .ssh / .aws / .gnupg 等敏感目录被拒(走 is_in_system_blacklist) assert!(validate_path("/root/.ssh/id_rsa").is_err()); assert!(validate_path("C:\\Users\\x\\.aws\\credentials").is_err()); } // ── handler 参数边界 ── #[tokio::test] async fn test_handler_missing_url_errors() { let args = json!({ "output_path": "/tmp/x" }); let err = execute_download_file(args, "/tmp/x".to_string()).await.unwrap_err(); assert!(format!("{}", err).contains("缺少 url")); } #[tokio::test] async fn test_handler_empty_url_errors() { let args = json!({ "url": " ", "output_path": "/tmp/x" }); let err = execute_download_file(args, "/tmp/x".to_string()).await.unwrap_err(); assert!(format!("{}", err).contains("url 不能为空")); } #[tokio::test] async fn test_handler_empty_output_path_errors() { let args = json!({ "url": "https://example.com/", "output_path": " " }); let err = execute_download_file(args, " ".to_string()).await.unwrap_err(); assert!(format!("{}", err).contains("output_path 不能为空")); } // ── SSRF 拦截:handler 入口 validate_url 拒私网/localhost/非 http 协议(不发请求) ── #[tokio::test] async fn test_handler_rejects_localhost() { let args = json!({ "url": "http://localhost:8080/secret", "output_path": "/tmp/x" }); let err = execute_download_file(args, "/tmp/x".to_string()).await.unwrap_err(); assert!(format!("{}", err).contains("localhost") || format!("{}", err).contains("SSRF")); } #[tokio::test] async fn test_handler_rejects_private_ip_loopback() { // 127.0.0.1 环回 let args = json!({ "url": "http://127.0.0.1/admin", "output_path": "/tmp/x" }); let err = execute_download_file(args, "/tmp/x".to_string()).await.unwrap_err(); assert!(format!("{}", err).contains("私网") || format!("{}", err).contains("SSRF")); } #[tokio::test] async fn test_handler_rejects_private_ip_rfc1918_10() { // 10.x RFC1918 A 类私网 let args = json!({ "url": "http://10.0.0.1/", "output_path": "/tmp/x" }); let err = execute_download_file(args, "/tmp/x".to_string()).await.unwrap_err(); assert!(format!("{}", err).contains("私网") || format!("{}", err).contains("SSRF")); } #[tokio::test] async fn test_handler_rejects_metadata_endpoint() { // 169.254.169.254 云元数据服务(SSRF 头号目标) let args = json!({ "url": "http://169.254.169.254/latest/meta-data/", "output_path": "/tmp/x" }); let err = execute_download_file(args, "/tmp/x".to_string()).await.unwrap_err(); assert!(format!("{}", err).contains("私网") || format!("{}", err).contains("SSRF")); } #[tokio::test] async fn test_handler_rejects_non_http_scheme() { // file/ftp/data 等非 http 协议被拒 let args = json!({ "url": "file:///etc/passwd", "output_path": "/tmp/x" }); let err = execute_download_file(args, "/tmp/x".to_string()).await.unwrap_err(); assert!(format!("{}", err).contains("协议")); } #[tokio::test] async fn test_handler_timeout_clamped_no_panic() { // timeout_secs 超大值应 clamp 到 MAX,不 panic(用 localhost 触发 SSRF 早期拒绝, // 验证 clamp 在拒绝前不 panic) let args = json!({ "url": "http://localhost/", "output_path": "/tmp/x", "timeout_secs": 999999 }); let _ = execute_download_file(args, "/tmp/x".to_string()).await; // 不 panic 即通过 } // ── 真实下载集成(#[ignore]:CI 无网时跳过,本地手跑) ── #[tokio::test] #[ignore = "需真实网络 + 临时目录,CI 无网时跳过:cargo test -- --ignored"] async fn integration_download_example_com() { // 下载 example.com 首页到临时文件,验证 happy path。 let tmp_dir = std::env::temp_dir().join("devflow-download-test"); tokio::fs::create_dir_all(&tmp_dir).await.ok(); let out = tmp_dir.join("example.html"); let out_str = out.to_string_lossy().to_string(); let args = json!({ "url": "https://example.com/", "output_path": out_str }); let result = execute_download_file(args, out_str.clone()).await.expect("下载应成功"); assert_eq!(result["status"].as_u64().unwrap(), 200); assert!(result["bytes_written"].as_u64().unwrap() > 0); assert!(result["url"].as_str().unwrap().contains("example.com")); // 文件确实落盘 let meta = tokio::fs::metadata(&out_str).await.expect("文件应存在"); assert!(meta.len() > 0); // 清理 let _ = tokio::fs::remove_file(&out_str).await; let _ = tokio::fs::remove_dir(&tmp_dir).await; } }