use std::path::{Component, Path, PathBuf}; use crate::error::StashError; /// 数据目录名:与二进制同级,存放可被用户直接修改的内容库。 pub const DATA_DIR_NAME: &str = "data"; /// 内容库文件名:固定为这个名字,一个实例一个库。 pub const DB_FILE_NAME: &str = "stash.db"; #[derive(Debug, Clone)] pub struct Config { /// 静态页面托管根目录(存放一个或多个前端项目) pub root: PathBuf, /// 数据目录:存放内容库与将来的备份 / 导出文件 pub data_dir: PathBuf, /// 内容库路径,固定为 `data/stash.db` pub db_path: PathBuf, /// 监听端口 pub port: u16, /// 监听地址,默认 127.0.0.1 pub host: String, /// 是否在根路径没有 index.html 时展示项目仪表盘 pub directory_listing: bool, /// 是否注入 CORS 标头 pub enable_cors: bool, } impl Default for Config { fn default() -> Self { // 默认值只在测试与兜底路径上使用,真实启动路径由 main.rs 解析 let data_dir = PathBuf::from(DATA_DIR_NAME); Self { root: PathBuf::from("../public"), db_path: resolve_db_path(&data_dir), data_dir, port: 8080, host: "127.0.0.1".to_string(), directory_listing: true, enable_cors: true, } } } /// 数据目录的默认位置:**二进制所在目录**下的 `data/`。 /// /// 之所以不用当前工作目录,是因为双击启动、快捷方式启动时 CWD 并不可控 /// (可能落在 System32 这种没写权限的位置),而数据库属于程序自身状态, /// 跟着 exe 走最稳。托管目录 `--root` 仍然保持 CWD 相对,行为不变。 pub fn default_data_dir() -> PathBuf { default_data_dir_from(std::env::current_exe().ok().as_deref()) } /// `default_data_dir` 的纯函数版本,便于在测试里用假路径验证。 fn default_data_dir_from(exe: Option<&Path>) -> PathBuf { match exe.and_then(Path::parent) { // 拿不到 exe 路径时退化成 CWD 相对目录,至少不会 panic Some(dir) => dir.join(DATA_DIR_NAME), None => PathBuf::from(DATA_DIR_NAME), } } /// 由数据目录推导出内容库路径。 pub fn resolve_db_path(data_dir: &Path) -> PathBuf { data_dir.join(DB_FILE_NAME) } /// 校验内容库没有落在静态托管目录里。 /// /// `ServeDir` 会把 `--root` 下的所有文件当静态资源对外提供, /// 一旦库文件在其中,任何人都能直接下载整个数据库,属于必须拦截的配置错误。 pub fn validate_db_location(root: &Path, db_path: &Path) -> Result<(), StashError> { let root = normalize(root); let db = normalize(db_path); if db.starts_with(&root) { return Err(StashError::InvalidDirectory(format!( "内容库不能放在托管目录内部:库={},托管目录={}。请用 --data 指定其它位置。", db.display(), root.display() ))); } Ok(()) } /// 去掉 Windows `canonicalize` 加上的 `\\?\` 前缀。 /// /// 该前缀本身合法,但会让 `Path::starts_with` 把它们当成不同的路径前缀: /// `\\?\D:\sites` 与 `D:\sites` 的 `Prefix` 组件不相等, /// 于是「库是否在托管目录内」的校验会被绕过。比较之前必须统一。 pub fn strip_verbatim(path: &Path) -> PathBuf { match path.as_os_str().to_string_lossy().strip_prefix(r"\\?\") { Some(stripped) => PathBuf::from(stripped), None => path.to_path_buf(), } } /// 展示用路径:去掉 `\\?\` 前缀后再转成字符串。 /// /// 前缀对程序无害,但打印在启动横幅和日志里很碍眼。 pub fn display_path(path: &Path) -> String { strip_verbatim(path).display().to_string() } /// 把路径转成绝对路径,并消掉 `.` / `..` 与重复分隔符。/// /// 这里刻意不做 `canonicalize`:校验发生在目录创建之前, /// 目标文件往往还不存在,而 `canonicalize` 要求路径真实存在。 /// 纯字符串层面的归一化已经足够判断「谁在谁内部」。 fn normalize(path: &Path) -> PathBuf { let absolute = if path.is_absolute() { path.to_path_buf() } else { std::env::current_dir() .unwrap_or_else(|_| PathBuf::from(".")) .join(path) }; // `canonicalize` 产出的 `\\?\` 前缀会让后续 starts_with 比较失效,先统一掉 let absolute = strip_verbatim(&absolute); let mut normalized = PathBuf::new(); for component in absolute.components() { match component { // 当前目录不产生任何变化 Component::CurDir => {} // 父目录回退一级;已经到根时 pop 是空操作,正好不会越界 Component::ParentDir => { normalized.pop(); } other => normalized.push(other.as_os_str()), } } normalized } #[cfg(test)] mod tests { use super::*; #[test] fn data_dir_follows_the_binary_directory() { let dir = default_data_dir_from(Some(Path::new(r"D:\apps\stash\stash.exe"))); assert_eq!(dir, PathBuf::from(r"D:\apps\stash\data")); } #[test] fn data_dir_falls_back_to_relative_when_exe_path_is_unknown() { assert_eq!(default_data_dir_from(None), PathBuf::from(DATA_DIR_NAME)); } #[test] fn db_path_is_fixed_inside_the_data_dir() { assert_eq!( resolve_db_path(Path::new(r"D:\apps\stash\data")), PathBuf::from(r"D:\apps\stash\data\stash.db") ); } #[test] fn rejects_database_nested_inside_the_hosted_root() { let err = validate_db_location( Path::new(r"D:\sites"), Path::new(r"D:\sites\data\stash.db"), ); assert!(err.is_err(), "库在托管目录内时必须拒绝启动"); } #[test] fn accepts_database_beside_the_hosted_root() { assert!( validate_db_location( Path::new(r"D:\stash\public"), Path::new(r"D:\stash\data\stash.db"), ) .is_ok() ); } #[test] fn location_check_survives_dot_segments() { // `..` 归一化之后应认出这是同一个目录,而不是被绕过 assert!( validate_db_location( Path::new(r"D:\stash\sites"), Path::new(r"D:\stash\sites\..\sites\stash.db"), ) .is_err() ); } /// 回归测试:`canonicalize` 会给路径加 `\\?\` 前缀, /// 若比较前不统一,库落在托管目录内这种配置错误会被漏过。 #[test] fn location_check_ignores_verbatim_prefix() { assert!( validate_db_location( Path::new(r"\\?\D:\stash\sites"), Path::new(r"D:\stash\sites\data\stash.db"), ) .is_err(), "带 \\\\?\\ 前缀的托管目录也必须能拦住库" ); assert!( validate_db_location( Path::new(r"D:\stash\sites"), Path::new(r"\\?\D:\stash\sites\data\stash.db"), ) .is_err(), "带 \\\\?\\ 前缀的库路径也必须能被识别" ); } #[test] fn verbatim_prefix_is_stripped() { assert_eq!( strip_verbatim(Path::new(r"\\?\D:\apps\stash")), PathBuf::from(r"D:\apps\stash") ); assert_eq!( strip_verbatim(Path::new(r"D:\apps\stash")), PathBuf::from(r"D:\apps\stash") ); } }