Просмотр исходного кода

fix(outline): 修复相对大纲路径导致目录污染

将 read_outline 的相对 path 固定解析到大纲目录,并拒绝越界路径。

读取不存在文件时不再提前创建 .cache,补充前后端回归测试。
darknessomi 1 месяц назад
Родитель
Сommit
fc5d80b666

+ 43 - 12
src-tauri/src/commands/fs.rs

@@ -216,19 +216,27 @@ fn cache_path_for(original: &Path) -> std::path::PathBuf {
         .file_name()
         .unwrap_or_default()
         .to_string_lossy();
-
-    // 如果缓存目录创建失败(如根目录无权限),回退到系统临时目录
-    if fs::create_dir_all(&cache_dir).is_err() {
-        return std::env::temp_dir()
-            .join("qmai-cache")
-            .join(format!("{}.txt", file_name));
-    }
     cache_dir.join(format!("{}.txt", file_name))
 }
 
+fn fallback_cache_path(original: &Path) -> std::path::PathBuf {
+    let file_name = original
+        .file_name()
+        .unwrap_or_default()
+        .to_string_lossy();
+    std::env::temp_dir()
+        .join("qmai-cache")
+        .join(format!("{}.txt", file_name))
+}
+
 fn read_cache(original: &Path) -> Option<String> {
-    let cache_path = cache_path_for(original);
     let original_modified = fs::metadata(original).ok()?.modified().ok()?;
+    let preferred_path = cache_path_for(original);
+    let cache_path = if fs::metadata(&preferred_path).is_ok() {
+        preferred_path
+    } else {
+        fallback_cache_path(original)
+    };
     let cache_modified = fs::metadata(&cache_path).ok()?.modified().ok()?;
     if cache_modified >= original_modified {
         fs::read_to_string(&cache_path).ok()
@@ -238,10 +246,18 @@ fn read_cache(original: &Path) -> Option<String> {
 }
 
 fn write_cache(original: &Path, text: &str) -> Result<(), String> {
-    let cache_path = cache_path_for(original);
-    if let Some(parent) = cache_path.parent() {
-        fs::create_dir_all(parent).ok();
-    }
+    let preferred_path = cache_path_for(original);
+    let cache_path = match preferred_path.parent() {
+        Some(parent) if fs::create_dir_all(parent).is_ok() => preferred_path,
+        _ => {
+            let fallback_path = fallback_cache_path(original);
+            if let Some(parent) = fallback_path.parent() {
+                fs::create_dir_all(parent)
+                    .map_err(|e| format!("Failed to create fallback cache directory: {e}"))?;
+            }
+            fallback_path
+        }
+    };
     crate::commands::file_sync::mark_app_write_path(&cache_path);
     fs::write(&cache_path, text)
         .map_err(|e| format!("Failed to write cache: {}", e))
@@ -1934,6 +1950,21 @@ mod tests {
     use super::*;
     use std::io::Write;
 
+    #[test]
+    fn missing_file_read_does_not_create_parent_cache_directory() {
+        let root = std::env::temp_dir().join(format!(
+            "qmai-missing-read-cache-{}",
+            std::time::SystemTime::now()
+                .duration_since(std::time::UNIX_EPOCH)
+                .unwrap()
+                .as_nanos()
+        ));
+        let missing_file = root.join("章纲").join("不存在.md");
+
+        assert!(do_read_file(&missing_file.to_string_lossy()).is_err());
+        assert!(!root.exists(), "读取不存在的文件不应创建缓存目录");
+    }
+
     #[test]
     fn directory_tree_includes_file_version_metadata() {
         let root = std::env::temp_dir().join(format!(

+ 21 - 1
src/lib/agent/tools/read-markdown-resource.ts

@@ -1,4 +1,5 @@
 import { listDirectory, readFile } from "@/commands/fs"
+import { isAbsolutePath, isPathInside, normalizePath } from "@/lib/path-utils"
 import type { FileNode } from "@/types/wiki"
 
 interface MarkdownCandidate {
@@ -14,6 +15,21 @@ interface DirectoryCandidate {
 
 export type ReadTextFile = (path: string) => Promise<string>
 
+function resolveExplicitResourcePath(baseDir: string, path: string): string | null {
+  const normalizedBase = normalizePath(baseDir).replace(/\/+$/, "")
+  const normalizedPath = normalizePath(path).trim()
+  if (!normalizedBase || !normalizedPath) return null
+
+  let candidate = normalizedPath
+  if (!isAbsolutePath(normalizedPath)) {
+    const segments = normalizedPath.split("/").filter((segment) => segment && segment !== ".")
+    if (segments.some((segment) => segment === "..")) return null
+    candidate = `${normalizedBase}/${segments.join("/")}`
+  }
+
+  return isPathInside(candidate, normalizedBase) ? candidate : null
+}
+
 function ensureMarkdownName(name: string): string {
   return name.toLowerCase().endsWith(".md") ? name : `${name}.md`
 }
@@ -140,8 +156,12 @@ export async function readMarkdownResource(
   const displayName = name || explicitPath
 
   if (explicitPath) {
+    const resolvedPath = resolveExplicitResourcePath(baseDir, explicitPath)
+    if (!resolvedPath) {
+      return `错误:无法读取${label}「${displayName}」,文件路径必须位于${label}目录内`
+    }
     try {
-      return await readTextFile(explicitPath)
+      return await readTextFile(resolvedPath)
     } catch {
       return `错误:无法读取${label}「${displayName}」,请确认文件存在`
     }

+ 2 - 2
src/lib/agent/tools/read-outline.ts

@@ -61,11 +61,11 @@ export function createReadOutlineTool(
 ): Tool {
   return {
     name: "read_outline",
-    description: "读取指定大纲文件的完整内容。参数 path 为大纲文件的完整路径,或 name 为大纲名称。",
+    description: "读取指定大纲文件的完整内容。参数 path 可为大纲目录内的完整路径或相对路径,或用 name 指定大纲名称。",
     category: "read",
     parameters: {
       name: { type: "string", description: "大纲名称" },
-      path: { type: "string", description: "大纲文件完整路径(可选,与 name 二选一)" },
+      path: { type: "string", description: "大纲文件完整路径或相对大纲目录的路径(可选,与 name 二选一)" },
     },
     execute: async (params) => {
       const result = await readMarkdownResource(outlinesDir, params, "大纲", readTextFile)

+ 20 - 0
src/lib/agent/tools/read-tools.spec.ts

@@ -149,6 +149,26 @@ describe("read tools", () => {
     expect(result).toBe("outline content")
   })
 
+  it("read_outline resolves an explicit relative path inside outlines dir", async () => {
+    vi.mocked(readFile).mockResolvedValue("nested outline content")
+    const tool = createReadOutlineTool("/project/wiki/outlines")
+
+    const result = await tool.execute({ path: "章纲/第9章-谋划.md" })
+
+    expect(result).toBe("nested outline content")
+    expect(readFile).toHaveBeenCalledWith("/project/wiki/outlines/章纲/第9章-谋划.md")
+    expect(readFile).not.toHaveBeenCalledWith("章纲/第9章-谋划.md")
+  })
+
+  it("read_outline rejects an explicit path outside outlines dir", async () => {
+    const tool = createReadOutlineTool("/project/wiki/outlines")
+
+    const result = await tool.execute({ path: "../chapters/第9章.md" })
+
+    expect(result).toContain("文件路径必须位于大纲目录内")
+    expect(readFile).not.toHaveBeenCalled()
+  })
+
   it("read_outline matches punctuation-insensitive outline names", async () => {
     vi.mocked(readFile).mockImplementation(async (path) => {
       if (path === "/project/wiki/outlines/他只想活着大纲.md") return "大纲内容"