fix: CLI 路径检查改用 is_safe_cli_path(允许绝对路径),更新文档

This commit is contained in:
2026-07-10 15:45:22 +08:00
parent ec3898ecb7
commit f7801b45a7
4 changed files with 40 additions and 23 deletions
+14 -10
View File
@@ -4,14 +4,14 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## 项目概述
Markdown 文档向量数据库 — 将 .md 文件分块 → 嵌入 → 存入 ChromaDB,通过 FastAPI HTTP 或 CLI 提供语义检索。
文档向量数据库 — 将 .md/.txt/.pdf/.html/.epub 文件分块 → 嵌入 → 存入 ChromaDB,通过 FastAPI HTTP 或 CLI 提供语义检索。
## 常用命令
```bash
```Shell
# --- 安装与测试 ---
uv sync # 安装依赖(lockfile 已锁定 CUDA torch
uv run pytest tests/ -v # 全部测试 (115+ 个)
uv run pytest tests/ -v # 全部测试 (120 个)
uv run pytest tests/test_api.py -v # 单个测试模块
uv run pytest tests/ -v -k "test_search" # 按名称过滤
@@ -36,6 +36,7 @@ uv run python scripts/ingest_obsidian.py
│ ├── db.py # VectorDB: 线程安全的 ChromaDB 封装
│ ├── embedder.py # 策略模式: LocalEmbedder / OpenAIEmbedder / DashscopeEmbedder
│ ├── ingest.py # DocumentIngestor: 按扩展名自动选择 Splitter
│ ├── security.py # 路径遍历防护 (is_safe_path + is_path_within_workspace)
│ ├── search.py # Searcher: 语义检索 + 源文件管理
│ └── splitters/ # 文档分块器包
│ ├── base.py # Splitter(Protocol) + BaseTextSplitter(ABC)
@@ -43,6 +44,7 @@ uv run python scripts/ingest_obsidian.py
│ ├── text.py # TextSplitter: 纯文本段落切分
│ ├── pdf.py # PDFSplitter: pymupdf 提取文字
│ ├── html.py # HTMLSplitter: bs4 去标签
│ ├── epub.py # EPUBSplitter: ebooklib 提取章节
│ └── registry.py # 扩展名 → Splitter 自动选择
server/ # FastAPI HTTP 层
@@ -55,7 +57,7 @@ cli/main.py # Typer CLI5 个命令 + --config 选项
**数据流**: 文件 → `get_splitter(path)` 自动选择 → `Splitter.split()``batch_embed()``ChromaDB collection.add()``Searcher.search()`
**支持的格式**: `.md` / `.txt` / `.pdf` / `.html` — 安装可选依赖: `uv sync --extra all`
**支持的格式**: `.md` / `.txt` / `.pdf` / `.html` / `.epub` — 安装可选依赖: `uv sync --extra all`
**依赖方向**: `config``db``embedder``ingest`/`search``server`/`cli`
@@ -90,7 +92,7 @@ cli/main.py # Typer CLI5 个命令 + --config 选项
- **API Key**: 环境变量 `MD_VECTOR_API_KEY``verify_api_key` 依赖注入到 ingest/search/delete 端点;未设置则跳过
- **速率限制**: `RateLimiter` 中间件,默认 60s 窗口内最多 30 请求
- **路径遍历防护**: `_is_safe_path()` 拒绝绝对路径`..` 穿越
- **路径遍历防护**: `is_path_within_workspace()` 拒绝绝对路径`..` 穿越和目录外访问
- **错误信息**: 500 返回通用消息,详细错误记入 `logger.exception`
### 配置系统 (config.py)
@@ -118,15 +120,16 @@ cli/main.py # Typer CLI5 个命令 + --config 选项
## 已入库知识库
| 集合名 | 来源 | 文件数 | chunks | 说明 |
|--------|------|--------|--------|------|
| `novel_taohou` | `D:\Code\doing_exercises\exercise\Novel\我有太后罩着,你们有什么\原有章节剧情` | 220 | 670 | 小说章节(GPU bge-small-v1.5 |
| `obsidian_blog` | `D:\Code\Obsidian` | 51 | 3,611 | 博客笔记(GPU bge-small-v1.5 |
| `default` | 测试文件 | 2 | ~30 | test-guide.md + stdin-doc.md |
| 集合名 | 来源 | 文件数 | chunks | 说明 |
| ----------------- | -------------------------------------------------------------------------------- | ------ | ------ | ------------------------------ |
| `novel_taohou` | `D:\Code\doing_exercises\exercise\Novel\我有太后罩着,你们有什么\原有章节剧情` | 220 | 670 | 小说章节(GPU bge-small-v1.5 |
| `obsidian_blog` | `D:\Code\Obsidian` | 51 | 3,611 | 博客笔记(GPU bge-small-v1.5 |
| `default` | 测试文件 | 2 | ~30 | test-guide.md + stdin-doc.md |
搜索时务必用 `-C` 指定集合,否则只会搜到 default 中的测试数据。
**小说搜索示例**
```bash
uv run md-vector-db search "张莽和孙太后的关系" -k 3 -C novel_taohou
uv run md-vector-db search "抄家事件" -k 5 -C novel_taohou
@@ -149,6 +152,7 @@ index-strategy = "unsafe-best-match" # 允许跨源查找
**不要删除 `[tool.uv]` 配置**,否则 `uv sync` 会重新解析为 CPU 版 torch。
### MarkdownSplitter 边界情况
`_split_single_paragraph` 中,当段落分隔符(。!?等)距 chunk 起点 < overlap(100) 时,
`start` 会回退为负数,Python `str.rfind` 的负索引会绕回文本末尾,造成死循环。
此 bug 已被修复(`start = max(start + 1, next_start)`),但给超长段落测试时需留意类似问题。
+10 -9
View File
@@ -4,14 +4,14 @@ Markdown 文档向量数据库 — 将 Markdown 文件自动分块、嵌入、
## 功能特性
- **文档入库**: 支持单文件、目录批量导入 Markdown 文档,自动按标题+段落智能分块
- **文档入库**: 支持单文件、目录批量导入多种格式文档,自动按标题+段落智能分块
- **语义检索**: 自然语言查询,返回最相关的文档片段及来源定位(文件名、章节标题)
- **多 Provider**: 本地模型 + 云端 APIOpenAI、阿里云 DashScope、硅基流动等 OpenAI 兼容服务)
- **GPU 加速**: 本地模型自动检测 CUDARTX 4060 实测:729 chunks 嵌入仅 1.8s
- **多集合**: 支持多项目数据隔离,不同知识库存入不同 ChromaDB collection
- **HTTP API**: FastAPI 提供 RESTful 接口,附带 Swagger 文档
- **安全**: 可选 API Key 认证、速率限制、路径遍历防护
- **多格式文档**: 支持 `.md` / `.txt` / `.pdf` / `.html`,按扩展名自动选择分块器,可通过 `Splitter` Protocol 扩展
- **多格式文档**: 支持 `.md` / `.txt` / `.pdf` / `.html` / `.epub`,按扩展名自动选择分块器,可通过 `Splitter` Protocol 扩展
- **去重**: 同一文件重复入库自动覆盖旧版本(基于路径 SHA256 哈希)
## 快速开始
@@ -52,7 +52,7 @@ embed:
# 单文件
uv run md-vector-db ingest docs/intro.md
# 批量导入目录(递归扫描所有 .md 文件
# 批量导入目录(递归扫描所有支持的文档格式
uv run md-vector-db ingest-dir ./md_docs/
# 指定集合(多项目数据隔离)
@@ -151,8 +151,8 @@ for r in resp.json()["results"]:
| 命令 | 说明 |
| --------------------------- | -------------------------------------------------------- |
| `ingest <文件路径>` | 入库单个 .md 文件,支持`-C` 指定集合 |
| `ingest-dir <目录路径>` | 递归入库目录下所有 .md 文件 |
| `ingest <文件路径>` | 入库单个文件(自动识别格式),支持`-C` 指定集合 |
| `ingest-dir <目录路径>` | 递归入库目录下所有支持的文档格式 |
| `search <查询> -k <数量>` | 语义检索,`-k` 默认 10、最大 100`--json` JSON 输出 |
| `stats` | 显示 chunks 总数、源文件列表 |
| `serve -p <端口>` | 启动 HTTP 服务(默认 8000 |
@@ -224,6 +224,7 @@ md-vector-db/
│ │ ├── db.py # ChromaDB 封装(线程安全)
│ │ ├── embedder.py # 嵌入器(Local/OpenAI/Dashscope
│ │ ├── ingest.py # 混合分块 + 入库
│ │ ├── security.py # 路径遍历防护
│ │ ├── search.py # 语义检索
│ │ └── splitters/ # 文档分块器包
│ │ ├── base.py # Splitter(Protocol) + BaseTextSplitter(ABC)
@@ -242,15 +243,15 @@ md-vector-db/
├── data/ # ChromaDB 持久化目录
├── md_docs/ # 待入库文档目录
├── scripts/
│ ├── serve.py # 快速启动脚本
│ └── ingest_obsidian.py # 批量入库 Obsidian 知识库
└── tests/ # 测试(115+ 个)
│ ├── serve.py # 快速启动脚本
│ └── ingest_obsidian.py # 批量入库 Obsidian 知识库
└── tests/ # 测试(120 个)
```
## 测试
```bash
uv run pytest tests/ -v # 全部测试 (115+ 个)
uv run pytest tests/ -v # 全部测试 (120 个)
uv run pytest tests/test_embedder.py -v # 嵌入器测试
uv run pytest tests/ -v -k "search" # 按名称过滤
uv run pytest tests/ -v --cov=src --cov-report=term-missing # 覆盖率
+4 -4
View File
@@ -17,7 +17,7 @@ if sys.stdout.encoding != "utf-8":
sys.path.insert(0, str(Path(__file__).parent.parent))
from src.core.config import DEFAULT_CONFIG_PATH
from src.core.security import is_path_within_workspace
from src.core.security import is_safe_cli_path
from src.server.deps import get_state, get_default_collection
app = typer.Typer(
@@ -82,7 +82,7 @@ def ingest(
if file_paths:
total = 0
for fp in file_paths:
if not is_path_within_workspace(fp):
if not is_safe_cli_path(fp):
typer.echo(f"[SKIP] 不安全的路径: {fp}", err=True)
continue
# 支持通配符 (shell 展开或 Python glob)
@@ -90,7 +90,7 @@ def ingest(
if "*" in fp or "?" in fp:
matches = _glob.glob(fp, recursive=True)
for m in matches:
if not is_path_within_workspace(m):
if not is_safe_cli_path(m):
typer.echo(f"[SKIP] 不安全的路径: {m}", err=True)
continue
c = ingestor.ingest_file(m)
@@ -117,7 +117,7 @@ def ingest_dir(
collection: CollectionOpt = None,
):
_init_config(config)
if not is_path_within_workspace(dir_path):
if not is_safe_cli_path(dir_path):
typer.echo(f"错误: 不安全的路径 — {dir_path}", err=True)
raise typer.Exit(code=1)
state = get_state()
+12
View File
@@ -46,3 +46,15 @@ def is_path_within_workspace(path_str: str) -> bool:
except ValueError:
return False
return common == cwd
def is_safe_cli_path(path_str: str) -> bool:
"""CLI 路径安全检查 — 仅拒绝 .. 穿越组件,允许绝对路径和任意目录。
CLI 是本地工具,用户有权限访问系统中任意路径。
与 is_path_within_workspaceAPI 用,绑定当前目录)相比更宽松。
"""
parts = path_str.replace("\\", "/").split("/")
if ".." in parts:
return False
return True