//! 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 { 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>, 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 { 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())), } }