修复策划工作区删除保护并允许链接路径删除
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m26s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m46s
Project CI / Backend tests (pull_request) Successful in 3m55s
Project CI / Frontend tests (pull_request) Successful in 2m13s
Project CI / Native shell tests (pull_request) Successful in 6m6s
Project CI / Repository checks (pull_request) Failing after 51s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 8m41s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 8m50s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m38s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m26s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m46s
Project CI / Backend tests (pull_request) Successful in 3m55s
Project CI / Frontend tests (pull_request) Successful in 2m13s
Project CI / Native shell tests (pull_request) Successful in 6m6s
Project CI / Repository checks (pull_request) Failing after 51s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 8m41s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 8m50s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m38s
按真实路径保护工作区根目录及其上级目录 允许删除路径经过祖先链接,删除链接本身不影响目标 补充两项删除边界回归测试并同步工具说明与文档
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
{"type":"function","function":{"name":"list_resources","description":"列出只读随包文档的逻辑目录、资源 ID、标题和简介;使用返回的资源 ID 读取文档。","parameters":{"type":"object","properties":{},"additionalProperties":false}}},
|
||||
{"type":"function","function":{"name":"read_resource","description":"读取一份固定资源文档全文。每次读取一个 resource_id;资源只读。读到内容缺失或不完整的文档时,由你自行判断和处理。","parameters":{"type":"object","properties":{"resource_id":{"type":"string"}},"required":["resource_id"],"additionalProperties":false}}},
|
||||
{"type":"function","function":{"name":"patch_file","description":"局部修改 UTF-8 文件。使用 old_text/new_text,或使用 edits 一次进行多个独立替换;每个 old_text 必须非空且在原文件中唯一。所有 edit 会一次性校验;任何失败都不修改文件,错误会列出各失败项及可唯一匹配的其余项。","parameters":{"type":"object","properties":{"path":{"type":"string"},"old_text":{"type":"string"},"new_text":{"type":"string"},"edits":{"type":"array","items":{"type":"object","properties":{"old_text":{"type":"string"},"new_text":{"type":"string"}},"required":["old_text","new_text"],"additionalProperties":false}}},"required":["path"],"additionalProperties":false}}},
|
||||
{"type":"function","function":{"name":"delete_path","description":"谨慎使用;永久删除文件或目录;目录会连同全部内容递归删除,不备份。先确认目标及删除范围。不能删除工作区根目录,也不能经过链接。","parameters":{"type":"object","properties":{"path":{"type":"string"}},"required":["path"],"additionalProperties":false}}},
|
||||
{"type":"function","function":{"name":"delete_path","description":"谨慎使用;永久删除文件或目录;目录会连同全部内容递归删除,不备份。先确认目标及删除范围。不能删除工作区根目录或包含它的上级目录。允许路径经过链接;删除链接本身只移除链接,递归删除目录时不跟随其中的目录链接。","parameters":{"type":"object","properties":{"path":{"type":"string"}},"required":["path"],"additionalProperties":false}}},
|
||||
{"type":"function","function":{"name":"list_dir","description":"列出目录中的文件和目录。","parameters":{"type":"object","properties":{"path":{"type":"string"}},"required":["path"],"additionalProperties":false}}},
|
||||
{"type":"function","function":{"name":"read_file","description":"读取 UTF-8 文本文件。","parameters":{"type":"object","properties":{"path":{"type":"string"}},"required":["path"],"additionalProperties":false}}},
|
||||
{"type":"function","function":{"name":"write_file","description":"创建或覆盖 UTF-8 文本文件。","parameters":{"type":"object","properties":{"path":{"type":"string"},"content":{"type":"string"}},"required":["path","content"],"additionalProperties":false}}},
|
||||
|
||||
@@ -403,21 +403,11 @@ pub(crate) fn execute_design_file_tool(
|
||||
}
|
||||
"delete_path" => {
|
||||
let relative = required_tool_path(args)?;
|
||||
let (display, path) = resolve_design_tool_path(root, &relative)?;
|
||||
if display == "." {
|
||||
return Err("不能删除工作区根目录".to_string());
|
||||
}
|
||||
if design_path_is_link(&path) {
|
||||
return Err("删除请使用目标的直接路径,不经过链接或路径折叠".to_string());
|
||||
}
|
||||
if !path.exists() {
|
||||
return Err(format!("路径不存在:{display}"));
|
||||
}
|
||||
if path.is_dir() {
|
||||
remove_design_dir(&path)?;
|
||||
} else {
|
||||
fs::remove_file(&path).map_err(|error| format!("删除失败:{error}"))?;
|
||||
}
|
||||
let workspace = ensure_design_workspace(root)?;
|
||||
// 保留 .. 的文件系统语义;祖先链接可以指向工作区外,不能提前词法折叠。
|
||||
let path = workspace.join(relative.replace('\\', "/"));
|
||||
let display = design_tool_location(root, &path);
|
||||
remove_design_path(&workspace, &path)?;
|
||||
Ok(Value::String(format!("已删除 {display}")))
|
||||
}
|
||||
"search_text" => {
|
||||
@@ -852,23 +842,31 @@ fn design_path_is_link(path: &Path) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_design_dir(path: &Path) -> Result<(), String> {
|
||||
fn remove_design_path(workspace: &Path, path: &Path) -> Result<(), String> {
|
||||
let metadata = fs::symlink_metadata(path).map_err(|error| format!("读取删除目标失败:{error}"))?;
|
||||
if design_path_is_link(path) {
|
||||
return Err("删除请使用目标的直接路径,不经过链接或路径折叠".to_string());
|
||||
}
|
||||
for entry in fs::read_dir(path).map_err(|error| format!("删除失败:{error}"))? {
|
||||
let entry = entry.map_err(|error| format!("删除失败:{error}"))?;
|
||||
let child = entry.path();
|
||||
if design_path_is_link(&child) {
|
||||
return Err("删除请使用目标的直接路径,不经过链接或路径折叠".to_string());
|
||||
}
|
||||
if child.is_dir() {
|
||||
remove_design_dir(&child)?;
|
||||
} else {
|
||||
fs::remove_file(&child).map_err(|error| format!("删除失败:{error}"))?;
|
||||
// Windows 目录链接 / junction 使用 RemoveDirectory,且不能解析到链接目标。
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::fs::MetadataExt;
|
||||
const FILE_ATTRIBUTE_DIRECTORY: u32 = 0x10;
|
||||
if metadata.file_attributes() & FILE_ATTRIBUTE_DIRECTORY != 0 {
|
||||
return fs::remove_dir(path).map_err(|error| format!("删除失败:{error}"));
|
||||
}
|
||||
}
|
||||
return fs::remove_file(path).map_err(|error| format!("删除失败:{error}"));
|
||||
}
|
||||
fs::remove_dir(path).map_err(|error| format!("删除失败:{error}"))
|
||||
if metadata.is_dir() {
|
||||
let target = fs::canonicalize(path).map_err(|error| format!("解析删除目标失败:{error}"))?;
|
||||
let workspace = fs::canonicalize(workspace)
|
||||
.map_err(|error| format!("解析策划工作区失败:{error}"))?;
|
||||
if workspace.starts_with(&target) {
|
||||
return Err("不能删除工作区根目录或包含它的上级目录".to_string());
|
||||
}
|
||||
// 标准递归删除只移除目录内链接本身,不遍历链接目标。
|
||||
return fs::remove_dir_all(target).map_err(|error| format!("删除失败:{error}"));
|
||||
}
|
||||
fs::remove_file(path).map_err(|error| format!("删除失败:{error}"))
|
||||
}
|
||||
|
||||
fn search_design_text(
|
||||
@@ -932,6 +930,65 @@ mod tests {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("design-agent")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_protects_workspace_before_removing_any_contents() {
|
||||
let temp = test_root();
|
||||
let root = temp.path();
|
||||
let workspace = ensure_design_workspace(root).unwrap();
|
||||
fs::create_dir(workspace.join("child")).unwrap();
|
||||
fs::write(workspace.join("keep.md"), "keep").unwrap();
|
||||
for path in [
|
||||
PathBuf::from("."),
|
||||
PathBuf::from(".."),
|
||||
workspace.clone(),
|
||||
workspace.join("child/.."),
|
||||
] {
|
||||
let error = execute_design_file_tool(root, "delete_path", &json!({"path":path}))
|
||||
.expect_err("protect workspace and ancestors");
|
||||
assert!(error.contains("不能删除工作区根目录"));
|
||||
assert_eq!(fs::read_to_string(workspace.join("keep.md")).unwrap(), "keep");
|
||||
assert!(workspace.join("child").is_dir());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn delete_allows_ancestor_links_and_unlinks_without_following_targets() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let temp = test_root();
|
||||
let root = temp.path();
|
||||
let workspace = ensure_design_workspace(root).unwrap();
|
||||
let outside = tempfile::tempdir().unwrap();
|
||||
fs::write(outside.path().join("delete.md"), "delete").unwrap();
|
||||
fs::write(outside.path().join("keep.md"), "keep").unwrap();
|
||||
symlink(outside.path(), workspace.join("alias")).unwrap();
|
||||
execute_design_file_tool(root, "delete_path", &json!({"path":"alias/delete.md"}))
|
||||
.expect("delete through ancestor link");
|
||||
assert!(!outside.path().join("delete.md").exists());
|
||||
|
||||
symlink(&workspace, workspace.join("self")).unwrap();
|
||||
execute_design_file_tool(root, "delete_path", &json!({"path":"self/."}))
|
||||
.expect_err("workspace alias is protected");
|
||||
execute_design_file_tool(root, "delete_path", &json!({"path":"self"}))
|
||||
.expect("unlink workspace alias only");
|
||||
execute_design_file_tool(root, "delete_path", &json!({"path":"alias"}))
|
||||
.expect("unlink directory alias only");
|
||||
|
||||
fs::create_dir(workspace.join("remove")).unwrap();
|
||||
symlink(outside.path(), workspace.join("remove/alias")).unwrap();
|
||||
symlink(outside.path().join("missing"), workspace.join("dangling")).unwrap();
|
||||
for path in ["remove", "dangling"] {
|
||||
execute_design_file_tool(root, "delete_path", &json!({"path":path})).unwrap();
|
||||
assert!(fs::symlink_metadata(workspace.join(path)).is_err());
|
||||
}
|
||||
assert_eq!(
|
||||
fs::read_to_string(outside.path().join("keep.md")).unwrap(),
|
||||
"keep"
|
||||
);
|
||||
assert!(workspace.is_dir());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_tools_edit_workspace_and_outside_files() {
|
||||
let temp = test_root();
|
||||
|
||||
Reference in New Issue
Block a user