229 lines
7.5 KiB
Rust
229 lines
7.5 KiB
Rust
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")
|
|
);
|
|
}
|
|
}
|