79 lines
2.6 KiB
Python
79 lines
2.6 KiB
Python
"""Vault 相对路径校验;所有文件操作必须先经过本模块。"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from pathlib import Path
|
|
|
|
from app.config import get_settings
|
|
from app.errors import ApiError
|
|
|
|
_INVALID_FILE_CHARS = re.compile(r'[\\/:*?"<>|]')
|
|
|
|
|
|
def normalize_folder(folder: str | None) -> str:
|
|
"""返回使用 `/` 的安全相对目录;根目录表示为空字符串。"""
|
|
|
|
if not folder or folder in {"/", "\\"}:
|
|
return ""
|
|
if "\x00" in folder:
|
|
raise ApiError(400, "INVALID_PATH", "folder must not contain NUL bytes")
|
|
segments: list[str] = []
|
|
for part in re.split(r"[\\/]+", folder):
|
|
if not part:
|
|
continue
|
|
if part in {".", ".."} or ":" in part:
|
|
raise ApiError(
|
|
400,
|
|
"INVALID_PATH",
|
|
"folder must be a relative path without '.' or '..' segments",
|
|
{"folder": folder},
|
|
)
|
|
segments.append(part)
|
|
return "/".join(segments)
|
|
|
|
|
|
def normalize_entry_name(name: str, *, markdown: bool = False) -> str:
|
|
"""校验单个目录项名称;不静默接受路径分隔符或保留段。"""
|
|
|
|
value = name.strip()
|
|
if not value or value in {".", ".."} or "\x00" in value:
|
|
raise ApiError(400, "INVALID_PATH", "entry name is invalid", {"name": name})
|
|
if _INVALID_FILE_CHARS.search(value):
|
|
raise ApiError(
|
|
400,
|
|
"INVALID_PATH",
|
|
"entry name contains unsupported characters",
|
|
{"name": name},
|
|
)
|
|
if markdown and not value.lower().endswith(".md"):
|
|
value += ".md"
|
|
return value
|
|
|
|
|
|
def safe_note_filename(title: str) -> str:
|
|
"""为创建笔记保留原有的宽松清洗行为。"""
|
|
|
|
value = _INVALID_FILE_CHARS.sub("_", title).strip() or "untitled"
|
|
return value if value.lower().endswith(".md") else f"{value}.md"
|
|
|
|
|
|
def resolve_in_vault(relative_path: str) -> Path:
|
|
"""把相对路径解析到当前 Vault,并拒绝符号链接/`..` 导致的越界。"""
|
|
|
|
if not relative_path or "\x00" in relative_path:
|
|
raise ApiError(
|
|
400, "INVALID_PATH", "invalid Vault-relative path", {"path": relative_path}
|
|
)
|
|
root = get_settings().vault_path.resolve()
|
|
candidate = (root / relative_path.replace("\\", "/").lstrip("/")).resolve()
|
|
if not candidate.is_relative_to(root):
|
|
raise ApiError(
|
|
400, "INVALID_PATH", "path escapes Vault", {"path": relative_path}
|
|
)
|
|
return candidate
|
|
|
|
|
|
def relative_to_vault(path: Path) -> str:
|
|
return path.resolve().relative_to(get_settings().vault_path.resolve()).as_posix()
|