mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-07-22 05:55:15 +08:00
317 lines
12 KiB
Rust
317 lines
12 KiB
Rust
//! Filesystem safety helpers for production-managed paths.
|
||
|
||
use std::env;
|
||
use std::fs::{self, File, OpenOptions};
|
||
use std::io::Write;
|
||
use std::path::{Component, Path, PathBuf};
|
||
|
||
#[cfg(unix)]
|
||
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
|
||
|
||
/// Private file mode used for daemon PID/status/control files.
|
||
pub const PRIVATE_FILE_MODE: u32 = 0o600;
|
||
|
||
/// Shared state file mode for non-secret manifests and snapshots.
|
||
pub const STATE_FILE_MODE: u32 = 0o600;
|
||
|
||
/// Validates a resource output directory before the updater writes under it.
|
||
pub fn validate_output_root(path: &Path) -> Result<(), String> {
|
||
validate_managed_directory(path, "资源输出目录")
|
||
}
|
||
|
||
/// Validates a daemon runtime/state directory before daemon files are written.
|
||
pub fn validate_runtime_state_dir(path: &Path) -> Result<(), String> {
|
||
validate_managed_directory(path, "后台状态目录")
|
||
}
|
||
|
||
/// Returns an absolute, lexical-normalized path without resolving symlinks.
|
||
pub fn lexical_absolute(path: &Path) -> Result<PathBuf, String> {
|
||
let absolute = if path.is_absolute() {
|
||
path.to_path_buf()
|
||
} else {
|
||
env::current_dir()
|
||
.map_err(|error| format!("读取当前目录失败:{error}"))?
|
||
.join(path)
|
||
};
|
||
Ok(lexically_normalize_path(&absolute))
|
||
}
|
||
|
||
/// Ensures `path` is lexically contained inside `root`.
|
||
pub fn ensure_path_within_root(root: &Path, path: &Path) -> Result<(), String> {
|
||
let root = lexical_absolute(root)?;
|
||
let path = lexical_absolute(path)?;
|
||
if path.starts_with(&root) {
|
||
Ok(())
|
||
} else {
|
||
Err(format!(
|
||
"路径逃逸:{} 不在 {} 下",
|
||
path.display(),
|
||
root.display()
|
||
))
|
||
}
|
||
}
|
||
|
||
/// Ensures a file target and all existing ancestors are safe to write.
|
||
pub fn ensure_safe_file_target(root: &Path, path: &Path, label: &str) -> Result<(), String> {
|
||
ensure_path_within_root(root, path)?;
|
||
ensure_no_symlink_in_existing_path(path, label)?;
|
||
match fs::symlink_metadata(path) {
|
||
Ok(metadata) if metadata.file_type().is_symlink() => {
|
||
Err(format!("{label} 不能是 symlink:{}", path.display()))
|
||
}
|
||
Ok(metadata) if metadata.is_file() => Ok(()),
|
||
Ok(_) => Err(format!("{label} 已存在但不是普通文件:{}", path.display())),
|
||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||
Err(error) => Err(format!("{label} 路径检查失败 {}:{error}", path.display())),
|
||
}
|
||
}
|
||
|
||
/// Ensures a directory path and all existing ancestors are not symlinks.
|
||
pub fn ensure_safe_directory_path(path: &Path, label: &str) -> Result<(), String> {
|
||
ensure_no_symlink_in_existing_path(path, label)?;
|
||
match fs::symlink_metadata(path) {
|
||
Ok(metadata) if metadata.file_type().is_symlink() => {
|
||
Err(format!("{label} 不能是 symlink:{}", path.display()))
|
||
}
|
||
Ok(metadata) if metadata.is_dir() => Ok(()),
|
||
Ok(_) => Err(format!("{label} 已存在但不是目录:{}", path.display())),
|
||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||
Err(error) => Err(format!("{label} 路径检查失败 {}:{error}", path.display())),
|
||
}
|
||
}
|
||
|
||
/// Reads a file without following a symlink at the file path.
|
||
pub fn read_file_no_symlink(path: &Path, label: &str) -> Result<Option<Vec<u8>>, String> {
|
||
let metadata = match fs::symlink_metadata(path) {
|
||
Ok(metadata) => metadata,
|
||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||
Err(error) => {
|
||
return Err(format!(
|
||
"读取 {label} 元数据失败 {}:{error}",
|
||
path.display()
|
||
))
|
||
}
|
||
};
|
||
if metadata.file_type().is_symlink() {
|
||
return Err(format!("{label} 不能是 symlink:{}", path.display()));
|
||
}
|
||
if !metadata.is_file() {
|
||
return Err(format!("{label} 不是普通文件:{}", path.display()));
|
||
}
|
||
fs::read(path)
|
||
.map(Some)
|
||
.map_err(|error| format!("读取 {label} 失败 {}:{error}", path.display()))
|
||
}
|
||
|
||
/// Writes a file atomically without following symlinks and sets a Unix mode.
|
||
pub fn write_file_atomic(path: &Path, bytes: &[u8], mode: u32, label: &str) -> Result<(), String> {
|
||
let parent = path
|
||
.parent()
|
||
.ok_or_else(|| format!("{label} 缺少父目录:{}", path.display()))?;
|
||
ensure_safe_directory_path(parent, label)?;
|
||
fs::create_dir_all(parent)
|
||
.map_err(|error| format!("创建 {label} 父目录失败 {}:{error}", parent.display()))?;
|
||
ensure_safe_directory_path(parent, label)?;
|
||
|
||
ensure_no_symlink_in_existing_path(path, label)?;
|
||
let temporary = temporary_path_for(path);
|
||
remove_existing_temporary(&temporary, label)?;
|
||
|
||
let mut options = OpenOptions::new();
|
||
options.write(true).create_new(true);
|
||
#[cfg(unix)]
|
||
options.mode(mode);
|
||
let mut file = options
|
||
.open(&temporary)
|
||
.map_err(|error| format!("创建临时 {label} 失败 {}:{error}", temporary.display()))?;
|
||
file.write_all(bytes)
|
||
.map_err(|error| format!("写入临时 {label} 失败 {}:{error}", temporary.display()))?;
|
||
file.flush()
|
||
.map_err(|error| format!("刷新临时 {label} 失败 {}:{error}", temporary.display()))?;
|
||
drop(file);
|
||
|
||
if let Ok(metadata) = fs::symlink_metadata(path) {
|
||
if metadata.file_type().is_symlink() {
|
||
let _ = fs::remove_file(&temporary);
|
||
return Err(format!("{label} 不能是 symlink:{}", path.display()));
|
||
}
|
||
if !metadata.is_file() {
|
||
let _ = fs::remove_file(&temporary);
|
||
return Err(format!("{label} 已存在但不是普通文件:{}", path.display()));
|
||
}
|
||
}
|
||
|
||
fs::rename(&temporary, path).map_err(|error| {
|
||
format!(
|
||
"移动临时 {label} 失败 {} -> {}:{error}",
|
||
temporary.display(),
|
||
path.display()
|
||
)
|
||
})?;
|
||
set_file_mode(path, mode, label)?;
|
||
Ok(())
|
||
}
|
||
|
||
/// Opens an append-only file safely without following symlinks at the target.
|
||
pub fn open_append_file(path: &Path, mode: u32, label: &str) -> Result<File, String> {
|
||
let parent = path
|
||
.parent()
|
||
.ok_or_else(|| format!("{label} 缺少父目录:{}", path.display()))?;
|
||
ensure_safe_directory_path(parent, label)?;
|
||
fs::create_dir_all(parent)
|
||
.map_err(|error| format!("创建 {label} 父目录失败 {}:{error}", parent.display()))?;
|
||
ensure_safe_directory_path(parent, label)?;
|
||
if let Ok(metadata) = fs::symlink_metadata(path) {
|
||
if metadata.file_type().is_symlink() {
|
||
return Err(format!("{label} 不能是 symlink:{}", path.display()));
|
||
}
|
||
if !metadata.is_file() {
|
||
return Err(format!("{label} 已存在但不是普通文件:{}", path.display()));
|
||
}
|
||
}
|
||
|
||
let mut options = OpenOptions::new();
|
||
options.create(true).append(true);
|
||
#[cfg(unix)]
|
||
options.mode(mode);
|
||
let file = options
|
||
.open(path)
|
||
.map_err(|error| format!("打开 {label} 失败 {}:{error}", path.display()))?;
|
||
set_file_mode(path, mode, label)?;
|
||
Ok(file)
|
||
}
|
||
|
||
/// Sets a file mode on Unix and is a no-op elsewhere.
|
||
pub fn set_file_mode(path: &Path, mode: u32, label: &str) -> Result<(), String> {
|
||
#[cfg(unix)]
|
||
{
|
||
fs::set_permissions(path, fs::Permissions::from_mode(mode))
|
||
.map_err(|error| format!("设置 {label} 权限失败 {}:{error}", path.display()))?;
|
||
}
|
||
#[cfg(not(unix))]
|
||
{
|
||
let _ = (path, mode, label);
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn validate_managed_directory(path: &Path, label: &str) -> Result<(), String> {
|
||
if path.as_os_str().is_empty() {
|
||
return Err(format!("{label} 不能为空"));
|
||
}
|
||
let absolute = lexical_absolute(path)?;
|
||
if is_dangerous_directory_root(&absolute) {
|
||
return Err(format!(
|
||
"{label} 是危险路径,不能直接使用:{}",
|
||
absolute.display()
|
||
));
|
||
}
|
||
ensure_safe_directory_path(&absolute, label)?;
|
||
if absolute.join(".git").exists() {
|
||
return Err(format!(
|
||
"{label} 不能是 Git 工作区根目录:{}",
|
||
absolute.display()
|
||
));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn is_dangerous_directory_root(path: &Path) -> bool {
|
||
const DANGEROUS: &[&str] = &[
|
||
"/", "/bin", "/boot", "/dev", "/etc", "/home", "/lib", "/lib64", "/opt", "/proc", "/root",
|
||
"/run", "/sbin", "/sys", "/tmp", "/usr", "/var", "/var/lib", "/var/run", "/var/tmp",
|
||
];
|
||
DANGEROUS
|
||
.iter()
|
||
.any(|candidate| path == Path::new(candidate))
|
||
}
|
||
|
||
fn ensure_no_symlink_in_existing_path(path: &Path, label: &str) -> Result<(), String> {
|
||
let absolute = lexical_absolute(path)?;
|
||
let mut current = PathBuf::new();
|
||
let mut saw_missing = false;
|
||
for component in absolute.components() {
|
||
push_component(&mut current, component);
|
||
if saw_missing {
|
||
continue;
|
||
}
|
||
match fs::symlink_metadata(¤t) {
|
||
Ok(metadata) if metadata.file_type().is_symlink() => {
|
||
return Err(format!(
|
||
"{label} 路径不能经过 symlink:{}",
|
||
current.display()
|
||
));
|
||
}
|
||
Ok(metadata) => {
|
||
if current != absolute && !metadata.is_dir() {
|
||
return Err(format!("{label} 父路径不是目录:{}", current.display()));
|
||
}
|
||
}
|
||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||
saw_missing = true;
|
||
}
|
||
Err(error) => {
|
||
return Err(format!(
|
||
"{label} 路径检查失败 {}:{error}",
|
||
current.display()
|
||
));
|
||
}
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn push_component(path: &mut PathBuf, component: Component<'_>) {
|
||
match component {
|
||
Component::Prefix(prefix) => path.push(prefix.as_os_str()),
|
||
Component::RootDir => path.push(Path::new("/")),
|
||
Component::CurDir => {}
|
||
Component::ParentDir => path.push(".."),
|
||
Component::Normal(value) => path.push(value),
|
||
}
|
||
}
|
||
|
||
fn lexically_normalize_path(path: &Path) -> PathBuf {
|
||
let mut normalized = PathBuf::new();
|
||
for component in path.components() {
|
||
match component {
|
||
Component::CurDir => {}
|
||
Component::ParentDir => {
|
||
if !normalized.pop() {
|
||
normalized.push(component.as_os_str());
|
||
}
|
||
}
|
||
_ => normalized.push(component.as_os_str()),
|
||
}
|
||
}
|
||
if normalized.as_os_str().is_empty() {
|
||
PathBuf::from(".")
|
||
} else {
|
||
normalized
|
||
}
|
||
}
|
||
|
||
fn temporary_path_for(path: &Path) -> PathBuf {
|
||
let file_name = path
|
||
.file_name()
|
||
.and_then(|value| value.to_str())
|
||
.unwrap_or("bat-state");
|
||
path.with_file_name(format!(".{file_name}.{}.tmp", std::process::id()))
|
||
}
|
||
|
||
fn remove_existing_temporary(path: &Path, label: &str) -> Result<(), String> {
|
||
match fs::symlink_metadata(path) {
|
||
Ok(metadata) if metadata.file_type().is_symlink() => {
|
||
Err(format!("临时 {label} 不能是 symlink:{}", path.display()))
|
||
}
|
||
Ok(metadata) if metadata.is_file() => fs::remove_file(path)
|
||
.map_err(|error| format!("清理临时 {label} 失败 {}:{error}", path.display())),
|
||
Ok(_) => Err(format!(
|
||
"临时 {label} 已存在但不是普通文件:{}",
|
||
path.display()
|
||
)),
|
||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||
Err(error) => Err(format!("读取临时 {label} 失败 {}:{error}", path.display())),
|
||
}
|
||
}
|