开关语义升级为完全放行,含 .ssh/.aws 等黑名单目录(产品拍板体验优先,用户知情选择) validate_path 与 is_authorized 双拦截点经进程级标志同源放行,audit 层每轮工具执行前刷新 设置页开启时二次确认,正文明示敏感凭据可进对话上下文风险自担;对话区常驻放行横幅既有 测试以串行闸防并行互踩进程级标志
330 lines
15 KiB
Rust
330 lines
15 KiB
Rust
//! 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<Value> {
|
|
// ── 参数解析 ──
|
|
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() {
|
|
// 依赖全局黑名单放行标志为关,持串行闸防与改写标志的并行测试互踩
|
|
let _guard = crate::commands::ai::audit::BLACKLIST_BYPASS_TEST_LOCK
|
|
.lock()
|
|
.unwrap_or_else(|e| e.into_inner());
|
|
// .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());
|
|
}
|
|
|
|
#[test]
|
|
fn test_validate_path_bypass_allows_sensitive_dirs() {
|
|
// 总开关开启:黑名单放行(validate_path 层),.. 遍历检查保留(路径合法性校验,非权限边界)。
|
|
// 先取结果再复位再断言,防 panic 泄漏标志污染其他测试。
|
|
let _guard = crate::commands::ai::audit::BLACKLIST_BYPASS_TEST_LOCK
|
|
.lock()
|
|
.unwrap_or_else(|e| e.into_inner());
|
|
crate::commands::ai::audit::set_blacklist_bypass(true);
|
|
let ssh_ok = validate_path("/root/.ssh/id_rsa").is_ok();
|
|
let aws_ok = validate_path("C:\\Users\\x\\.aws\\credentials").is_ok();
|
|
let traversal_still_denied = validate_path("/root/.ssh/../.gnupg/x").is_err();
|
|
crate::commands::ai::audit::set_blacklist_bypass(false);
|
|
assert!(ssh_ok, "bypass 开时 .ssh 路径应通过 validate_path");
|
|
assert!(aws_ok, "bypass 开时 .aws 路径应通过 validate_path");
|
|
assert!(traversal_still_denied, "bypass 开时 .. 遍历检查应保留");
|
|
}
|
|
|
|
// ── 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;
|
|
}
|
|
}
|