Files
BlueArchiveToolkit/infrastructure/src/path_security.rs
T

317 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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(&current) {
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())),
}
}