mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-07-22 01:15:14 +08:00
@@ -1,13 +1,17 @@
|
||||
use bat_adapters::official::yostar_jp::PatchPlatform;
|
||||
use bat_infrastructure::{
|
||||
OfficialServerInfoSource, OfficialUpdateConfig, OfficialUpdateProgress, OfficialUpdateReport,
|
||||
OfficialUpdateService, OfficialUpdateStatus, OfficialVerificationSummary,
|
||||
open_append_file, read_file_no_symlink, validate_output_root, validate_runtime_state_dir,
|
||||
write_file_atomic, OfficialServerInfoSource, OfficialUpdateConfig, OfficialUpdateProgress,
|
||||
OfficialUpdateReport, OfficialUpdateService, OfficialUpdateStatus, OfficialVerificationSummary,
|
||||
PRIVATE_FILE_MODE,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::env;
|
||||
use std::fs::{self, OpenOptions};
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::net::{UnixListener, UnixStream};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
@@ -234,6 +238,9 @@ fn run_watch(options: CliOptions) -> anyhow::Result<()> {
|
||||
if options.error_retry_interval.is_zero() {
|
||||
return Err(anyhow::anyhow!("watch 失败重试间隔必须大于 0"));
|
||||
}
|
||||
if options.daemon_child {
|
||||
validate_runtime_state_dir(&options.state_dir).map_err(anyhow::Error::msg)?;
|
||||
}
|
||||
let service = OfficialUpdateService::new();
|
||||
let mut logger = ProgressLogger::new(options.progress);
|
||||
let daemon_state_dir = options.state_dir.clone();
|
||||
@@ -576,11 +583,16 @@ struct DaemonControlLock {
|
||||
|
||||
impl DaemonControlLock {
|
||||
fn acquire(state_dir: &Path) -> anyhow::Result<Self> {
|
||||
validate_runtime_state_dir(state_dir).map_err(anyhow::Error::msg)?;
|
||||
fs::create_dir_all(state_dir)?;
|
||||
let path = daemon_control_lock_path(state_dir);
|
||||
let pid = std::process::id();
|
||||
for attempt in 0..=1 {
|
||||
match OpenOptions::new().write(true).create_new(true).open(&path) {
|
||||
let mut options = OpenOptions::new();
|
||||
options.write(true).create_new(true);
|
||||
#[cfg(unix)]
|
||||
options.mode(PRIVATE_FILE_MODE);
|
||||
match options.open(&path) {
|
||||
Ok(mut file) => {
|
||||
file.write_all(pid.to_string().as_bytes())?;
|
||||
return Ok(Self { path, pid });
|
||||
@@ -611,6 +623,12 @@ impl DaemonControlLock {
|
||||
impl Drop for DaemonControlLock {
|
||||
fn drop(&mut self) {
|
||||
let expected = self.pid.to_string();
|
||||
if fs::symlink_metadata(&self.path)
|
||||
.map(|metadata| metadata.file_type().is_symlink())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if fs::read_to_string(&self.path)
|
||||
.map(|contents| contents.trim() == expected)
|
||||
.unwrap_or(false)
|
||||
@@ -629,13 +647,20 @@ enum PidLockState {
|
||||
}
|
||||
|
||||
fn classify_pid_lock_file(path: &Path) -> anyhow::Result<PidLockState> {
|
||||
let contents = match fs::read_to_string(path) {
|
||||
Ok(contents) => contents,
|
||||
let metadata = match fs::symlink_metadata(path) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
return Ok(PidLockState::Missing);
|
||||
}
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
if metadata.file_type().is_symlink() || !metadata.is_file() {
|
||||
return Ok(PidLockState::Corrupt);
|
||||
}
|
||||
let contents = match fs::read_to_string(path) {
|
||||
Ok(contents) => contents,
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
let Some(pid) = parse_pid_value(&contents) else {
|
||||
return Ok(PidLockState::Corrupt);
|
||||
};
|
||||
@@ -871,9 +896,11 @@ fn start_daemon_rpc_server(
|
||||
state_dir: &Path,
|
||||
control: DaemonControl,
|
||||
) -> anyhow::Result<DaemonRpcServer> {
|
||||
validate_runtime_state_dir(state_dir).map_err(anyhow::Error::msg)?;
|
||||
fs::create_dir_all(state_dir)?;
|
||||
let socket_path = daemon_socket_path(state_dir);
|
||||
if socket_path.exists() {
|
||||
if daemon_socket_path_exists(&socket_path)? {
|
||||
ensure_daemon_socket_not_symlink(&socket_path)?;
|
||||
match UnixStream::connect(&socket_path) {
|
||||
Ok(_) => {
|
||||
return Err(anyhow::anyhow!(
|
||||
@@ -1090,6 +1117,7 @@ fn daemon_rpc_call(
|
||||
params: Option<serde_json::Value>,
|
||||
) -> anyhow::Result<serde_json::Value> {
|
||||
let socket_path = daemon_socket_path(state_dir);
|
||||
ensure_daemon_socket_not_symlink(&socket_path)?;
|
||||
let mut stream = UnixStream::connect(&socket_path).map_err(|error| {
|
||||
anyhow::anyhow!("无法连接后台 RPC socket {}:{error}", socket_path.display())
|
||||
})?;
|
||||
@@ -1132,7 +1160,9 @@ fn daemon_rpc_call(
|
||||
|
||||
#[cfg(unix)]
|
||||
fn daemon_rpc_available(state_dir: &Path) -> bool {
|
||||
UnixStream::connect(daemon_socket_path(state_dir)).is_ok()
|
||||
let socket_path = daemon_socket_path(state_dir);
|
||||
ensure_daemon_socket_not_symlink(&socket_path).is_ok()
|
||||
&& UnixStream::connect(socket_path).is_ok()
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
@@ -1140,6 +1170,42 @@ fn daemon_rpc_available(_state_dir: &Path) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn ensure_daemon_socket_not_symlink(socket_path: &Path) -> anyhow::Result<()> {
|
||||
match fs::symlink_metadata(socket_path) {
|
||||
Ok(metadata) if metadata.file_type().is_symlink() => Err(anyhow::anyhow!(
|
||||
"后台 RPC socket 不能是 symlink:{}",
|
||||
socket_path.display()
|
||||
)),
|
||||
Ok(_) => Ok(()),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(error) => Err(error.into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn daemon_socket_path_exists(socket_path: &Path) -> anyhow::Result<bool> {
|
||||
path_exists_no_follow(socket_path).map_err(|error| {
|
||||
anyhow::anyhow!(
|
||||
"后台 RPC socket 路径检查失败 {}:{error}",
|
||||
socket_path.display()
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn daemon_socket_path_exists(socket_path: &Path) -> anyhow::Result<bool> {
|
||||
path_exists_no_follow(socket_path)
|
||||
}
|
||||
|
||||
fn path_exists_no_follow(path: &Path) -> anyhow::Result<bool> {
|
||||
match fs::symlink_metadata(path) {
|
||||
Ok(_) => Ok(true),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
|
||||
Err(error) => Err(error.into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct DaemonStartReport {
|
||||
status: &'static str,
|
||||
@@ -1218,6 +1284,7 @@ fn start_daemon_with_args(
|
||||
args: Vec<String>,
|
||||
message: &'static str,
|
||||
) -> anyhow::Result<DaemonStartReport> {
|
||||
validate_runtime_state_dir(&state_dir).map_err(anyhow::Error::msg)?;
|
||||
fs::create_dir_all(&state_dir)?;
|
||||
let pid_path = daemon_pid_path(&state_dir);
|
||||
let status_path = daemon_status_path(&state_dir);
|
||||
@@ -1238,10 +1305,8 @@ fn start_daemon_with_args(
|
||||
}
|
||||
|
||||
let executable = env::current_exe()?;
|
||||
let log = OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&log_path)?;
|
||||
let log =
|
||||
open_append_file(&log_path, PRIVATE_FILE_MODE, "后台日志").map_err(anyhow::Error::msg)?;
|
||||
let log_for_stdout = log.try_clone()?;
|
||||
let mut command = Command::new(&executable);
|
||||
command
|
||||
@@ -1253,7 +1318,17 @@ fn start_daemon_with_args(
|
||||
let child = command.spawn()?;
|
||||
let pid = child.id();
|
||||
|
||||
fs::write(&pid_path, pid.to_string())?;
|
||||
if let Err(error) = write_file_atomic(
|
||||
&pid_path,
|
||||
pid.to_string().as_bytes(),
|
||||
PRIVATE_FILE_MODE,
|
||||
"后台 PID 文件",
|
||||
)
|
||||
.map_err(anyhow::Error::msg)
|
||||
{
|
||||
let _ = terminate_process(pid);
|
||||
return Err(error);
|
||||
}
|
||||
let mut command = Vec::with_capacity(args.len() + 1);
|
||||
command.push(executable.to_string_lossy().to_string());
|
||||
command.extend(args);
|
||||
@@ -1426,7 +1501,9 @@ fn build_daemon_status_report(state_dir: &Path) -> anyhow::Result<DaemonStatusRe
|
||||
pid_file_state,
|
||||
PidLockState::StalePid(_) | PidLockState::Corrupt
|
||||
);
|
||||
let stale_socket = socket_path.exists() && !rpc_available;
|
||||
let stale_socket = daemon_socket_path_exists(&socket_path)? && !rpc_available;
|
||||
let default_log_path = daemon_log_path(state_dir);
|
||||
let default_log_exists = path_exists_no_follow(&default_log_path)?;
|
||||
|
||||
Ok(DaemonStatusReport {
|
||||
status: if running { "running" } else { "stopped" },
|
||||
@@ -1452,7 +1529,7 @@ fn build_daemon_status_report(state_dir: &Path) -> anyhow::Result<DaemonStatusRe
|
||||
log_path: status_file
|
||||
.as_ref()
|
||||
.map(|status| status.log_path.clone())
|
||||
.or_else(|| Some(daemon_log_path(state_dir)).filter(|path| path.exists())),
|
||||
.or_else(|| default_log_exists.then_some(default_log_path)),
|
||||
started_unix_seconds: status_file
|
||||
.as_ref()
|
||||
.map(|status| status.started_unix_seconds),
|
||||
@@ -2203,15 +2280,9 @@ fn build_logs_report(state_dir: &Path, tail_lines: usize) -> anyhow::Result<Logs
|
||||
.as_ref()
|
||||
.map(|status| status.log_path.clone())
|
||||
.unwrap_or_else(|| daemon_log_path(state_dir));
|
||||
let mut exists = true;
|
||||
let bytes = match fs::read(&log_path) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(),
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
if bytes.is_empty() && !log_path.exists() {
|
||||
exists = false;
|
||||
}
|
||||
let bytes = read_file_no_symlink(&log_path, "后台日志").map_err(anyhow::Error::msg)?;
|
||||
let exists = bytes.is_some();
|
||||
let bytes = bytes.unwrap_or_default();
|
||||
let raw = String::from_utf8_lossy(&bytes);
|
||||
let lines = raw.lines().collect::<Vec<_>>();
|
||||
let start = lines.len().saturating_sub(tail_lines);
|
||||
@@ -2262,6 +2333,16 @@ fn run_doctor_command(options: &CliOptions) -> anyhow::Result<bool> {
|
||||
&options.config.output_root,
|
||||
"资源输出目录可用",
|
||||
));
|
||||
checks.push(safety_check(
|
||||
"output_root_safety",
|
||||
validate_output_root(&options.config.output_root),
|
||||
"资源输出目录安全边界通过",
|
||||
));
|
||||
checks.push(safety_check(
|
||||
"state_dir_safety",
|
||||
validate_runtime_state_dir(&options.state_dir),
|
||||
"后台状态目录安全边界通过",
|
||||
));
|
||||
checks.push(command_check("curl", &options.config.curl_command));
|
||||
checks.push(command_check("unzip", &options.config.unzip_command));
|
||||
|
||||
@@ -2294,7 +2375,7 @@ fn run_doctor_command(options: &CliOptions) -> anyhow::Result<bool> {
|
||||
}
|
||||
|
||||
let socket_path = daemon_socket_path(&options.state_dir);
|
||||
let socket_exists = socket_path.exists();
|
||||
let socket_exists = daemon_socket_path_exists(&socket_path).unwrap_or(false);
|
||||
let socket_available = daemon_rpc_available(&options.state_dir);
|
||||
checks.push(DoctorCheck {
|
||||
name: "daemon_rpc",
|
||||
@@ -2438,6 +2519,21 @@ fn path_check(name: &'static str, path: &Path, ready_message: &str) -> DoctorChe
|
||||
DoctorCheck { name, ok, message }
|
||||
}
|
||||
|
||||
fn safety_check(name: &'static str, result: Result<(), String>, ok_message: &str) -> DoctorCheck {
|
||||
match result {
|
||||
Ok(()) => DoctorCheck {
|
||||
name,
|
||||
ok: true,
|
||||
message: ok_message.to_string(),
|
||||
},
|
||||
Err(error) => DoctorCheck {
|
||||
name,
|
||||
ok: false,
|
||||
message: error,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn command_check(name: &'static str, command: &Path) -> DoctorCheck {
|
||||
let result = Command::new(command).arg("--version").output();
|
||||
DoctorCheck {
|
||||
@@ -2466,6 +2562,8 @@ struct CleanStableReport {
|
||||
}
|
||||
|
||||
fn run_clean_stable_command(options: &CliOptions) -> anyhow::Result<()> {
|
||||
validate_output_root(&options.config.output_root).map_err(anyhow::Error::msg)?;
|
||||
validate_runtime_state_dir(&options.state_dir).map_err(anyhow::Error::msg)?;
|
||||
let status = build_daemon_status_report(&options.state_dir)?;
|
||||
if status.running || status.rpc_available {
|
||||
return Err(anyhow::anyhow!(
|
||||
@@ -2490,7 +2588,7 @@ fn run_clean_stable_command(options: &CliOptions) -> anyhow::Result<()> {
|
||||
}
|
||||
|
||||
let socket_path = daemon_socket_path(&options.state_dir);
|
||||
if socket_path.exists() {
|
||||
if daemon_socket_path_exists(&socket_path)? {
|
||||
if daemon_rpc_available(&options.state_dir) {
|
||||
skipped_paths.push(socket_path);
|
||||
} else {
|
||||
@@ -2571,21 +2669,21 @@ fn collect_transient_files(root: &Path, removed_paths: &mut Vec<PathBuf>) -> any
|
||||
}
|
||||
|
||||
fn read_daemon_status_file(path: &Path) -> anyhow::Result<Option<DaemonStatusFile>> {
|
||||
let bytes = match fs::read(path) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(error) => return Err(error.into()),
|
||||
let Some(bytes) = read_file_no_symlink(path, "后台状态文件").map_err(anyhow::Error::msg)?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(Some(serde_json::from_slice(&bytes)?))
|
||||
}
|
||||
|
||||
fn write_daemon_status_file(path: &Path, status: &DaemonStatusFile) -> anyhow::Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
let temporary = path.with_extension("json.tmp");
|
||||
fs::write(&temporary, serde_json::to_vec_pretty(status)?)?;
|
||||
fs::rename(&temporary, path)?;
|
||||
write_file_atomic(
|
||||
path,
|
||||
&serde_json::to_vec_pretty(status)?,
|
||||
PRIVATE_FILE_MODE,
|
||||
"后台状态文件",
|
||||
)
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -2639,11 +2737,11 @@ fn update_daemon_state_only(state_dir: &Path, state: &str) -> anyhow::Result<()>
|
||||
}
|
||||
|
||||
fn read_pid_file(path: &Path) -> anyhow::Result<Option<u32>> {
|
||||
let contents = match fs::read_to_string(path) {
|
||||
Ok(contents) => contents,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(error) => return Err(error.into()),
|
||||
let Some(bytes) = read_file_no_symlink(path, "后台 PID 文件").map_err(anyhow::Error::msg)?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let contents = String::from_utf8(bytes)?;
|
||||
Ok(contents.trim().parse::<u32>().ok().filter(|pid| *pid > 0))
|
||||
}
|
||||
|
||||
@@ -3779,6 +3877,24 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn daemon_rpc_rejects_socket_symlink() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let temp = tempfile::TempDir::new().unwrap();
|
||||
let state_dir = temp.path().join("state");
|
||||
fs::create_dir_all(&state_dir).unwrap();
|
||||
let outside_socket = temp.path().join("outside.sock");
|
||||
let socket_path = daemon_socket_path(&state_dir);
|
||||
symlink(&outside_socket, &socket_path).unwrap();
|
||||
|
||||
assert!(daemon_socket_path_exists(&socket_path).unwrap());
|
||||
assert!(!daemon_rpc_available(&state_dir));
|
||||
let error = daemon_rpc_call(&state_dir, RPC_METHOD_STATUS, None).unwrap_err();
|
||||
assert!(error.to_string().contains("symlink"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rpc_tail_param_defaults_and_rejects_zero() {
|
||||
assert_eq!(rpc_tail_param(None, 200).unwrap(), 200);
|
||||
@@ -3870,6 +3986,65 @@ mod tests {
|
||||
assert!(!lock_path.exists());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn daemon_control_lock_file_is_private() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let temp = tempfile::TempDir::new().unwrap();
|
||||
let state_dir = temp.path().join("state");
|
||||
let lock_path = daemon_control_lock_path(&state_dir);
|
||||
let lock = DaemonControlLock::acquire(&state_dir).unwrap();
|
||||
|
||||
let mode = fs::metadata(&lock_path).unwrap().permissions().mode() & 0o777;
|
||||
assert_eq!(mode, 0o600);
|
||||
drop(lock);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn daemon_status_file_is_private_and_rejects_symlink() {
|
||||
use std::os::unix::fs::{symlink, PermissionsExt};
|
||||
|
||||
let temp = tempfile::TempDir::new().unwrap();
|
||||
let state_dir = temp.path().join("state");
|
||||
let output_root = temp.path().join("resources");
|
||||
fs::create_dir_all(&state_dir).unwrap();
|
||||
fs::create_dir_all(&output_root).unwrap();
|
||||
let status_path = daemon_status_path(&state_dir);
|
||||
let status = test_daemon_status_file(&state_dir, &output_root);
|
||||
|
||||
write_daemon_status_file(&status_path, &status).unwrap();
|
||||
let mode = fs::metadata(&status_path).unwrap().permissions().mode() & 0o777;
|
||||
assert_eq!(mode, 0o600);
|
||||
|
||||
fs::remove_file(&status_path).unwrap();
|
||||
let outside = temp.path().join("outside-status.json");
|
||||
fs::write(&outside, b"outside").unwrap();
|
||||
symlink(&outside, &status_path).unwrap();
|
||||
|
||||
let error = write_daemon_status_file(&status_path, &status).unwrap_err();
|
||||
assert!(error.to_string().contains("symlink"));
|
||||
assert_eq!(fs::read(&outside).unwrap(), b"outside");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn read_pid_file_rejects_symlink() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let temp = tempfile::TempDir::new().unwrap();
|
||||
let state_dir = temp.path().join("state");
|
||||
fs::create_dir_all(&state_dir).unwrap();
|
||||
let outside = temp.path().join("outside.pid");
|
||||
fs::write(&outside, b"123").unwrap();
|
||||
let pid_path = daemon_pid_path(&state_dir);
|
||||
symlink(&outside, &pid_path).unwrap();
|
||||
|
||||
let error = read_pid_file(&pid_path).unwrap_err();
|
||||
assert!(error.to_string().contains("symlink"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_daemon_resource_conflict_detects_same_output() {
|
||||
let temp = tempfile::TempDir::new().unwrap();
|
||||
|
||||
@@ -18,6 +18,7 @@ pub mod official_launcher;
|
||||
pub mod official_pull;
|
||||
pub mod official_sync;
|
||||
pub mod official_update;
|
||||
pub mod path_security;
|
||||
pub mod resources;
|
||||
mod zip_validation;
|
||||
|
||||
@@ -52,6 +53,11 @@ pub use official_update::{
|
||||
OfficialUpdateConfig, OfficialUpdateProgress, OfficialUpdateReport, OfficialUpdateService,
|
||||
OfficialUpdateSnapshot, OfficialUpdateStatus, OfficialVerificationSummary, ResolvedBootstrap,
|
||||
};
|
||||
pub use path_security::{
|
||||
ensure_path_within_root, ensure_safe_directory_path, ensure_safe_file_target, lexical_absolute,
|
||||
open_append_file, read_file_no_symlink, set_file_mode, validate_output_root,
|
||||
validate_runtime_state_dir, write_file_atomic, PRIVATE_FILE_MODE, STATE_FILE_MODE,
|
||||
};
|
||||
pub use resources::{InMemoryResourceRepository, SqliteResourceRepository};
|
||||
|
||||
/// Infrastructure 版本号
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
//! Official JP resource download execution.
|
||||
|
||||
use crate::official_pull::OfficialResourcePullPlan;
|
||||
use crate::path_security::{
|
||||
ensure_path_within_root, ensure_safe_directory_path, ensure_safe_file_target,
|
||||
read_file_no_symlink, validate_output_root, write_file_atomic, STATE_FILE_MODE,
|
||||
};
|
||||
use crate::zip_validation::{
|
||||
path_has_zip_extension, url_or_path_has_zip_extension, validate_zip_structure,
|
||||
};
|
||||
@@ -456,12 +460,7 @@ impl OfficialResourcePullService {
|
||||
mut progress: impl FnMut(OfficialResourcePullProgress),
|
||||
mut should_cancel: impl FnMut() -> bool,
|
||||
) -> Result<OfficialResourcePullReport, String> {
|
||||
fs::create_dir_all(&self.output_root).map_err(|error| {
|
||||
format!(
|
||||
"创建资源输出目录失败 {}:{error}",
|
||||
self.output_root.display()
|
||||
)
|
||||
})?;
|
||||
self.ensure_output_root_ready()?;
|
||||
|
||||
let official_hash_pairs = official_seed_hash_pairs(plan);
|
||||
let force_refresh_urls = official_hash_refresh_urls(&official_hash_pairs);
|
||||
@@ -490,10 +489,13 @@ impl OfficialResourcePullService {
|
||||
|
||||
let destination = self.destination_for_url(&url)?;
|
||||
if let Some(parent) = destination.parent() {
|
||||
ensure_safe_directory_path(parent, "下载目标目录")?;
|
||||
fs::create_dir_all(parent).map_err(|error| {
|
||||
format!("创建下载目标目录失败 {}:{error}", parent.display())
|
||||
})?;
|
||||
ensure_safe_directory_path(parent, "下载目标目录")?;
|
||||
}
|
||||
ensure_safe_file_target(&self.output_root, &destination, "下载目标文件")?;
|
||||
|
||||
let force_refresh = force_refresh_urls.contains(&url);
|
||||
let result = if force_refresh {
|
||||
@@ -679,7 +681,9 @@ impl OfficialResourcePullService {
|
||||
}
|
||||
|
||||
fn pull_one(&self, url: &str, destination: &Path) -> Result<PullOneResult, String> {
|
||||
ensure_safe_file_target(&self.output_root, destination, "下载目标文件")?;
|
||||
let partial = partial_path_for(destination);
|
||||
ensure_safe_file_target(&self.output_root, &partial, "临时下载文件")?;
|
||||
let partial_len_before = file_len_if_exists(&partial)?.unwrap_or_default();
|
||||
let resumed = partial_len_before > 0;
|
||||
let mut status = if resumed {
|
||||
@@ -712,11 +716,13 @@ impl OfficialResourcePullService {
|
||||
return Err(error);
|
||||
}
|
||||
|
||||
ensure_safe_file_target(&self.output_root, destination, "下载目标文件")?;
|
||||
if file_len_if_exists(destination)?.is_some() {
|
||||
fs::remove_file(destination).map_err(|error| {
|
||||
format!("替换已有目标文件失败 {}:{error}", destination.display())
|
||||
})?;
|
||||
}
|
||||
ensure_safe_file_target(&self.output_root, destination, "下载目标文件")?;
|
||||
|
||||
fs::rename(&partial, destination).map_err(|error| {
|
||||
format!(
|
||||
@@ -726,9 +732,8 @@ impl OfficialResourcePullService {
|
||||
)
|
||||
})?;
|
||||
|
||||
let bytes = fs::metadata(destination)
|
||||
.map_err(|error| format!("下载完成后目标文件缺失 {}:{error}", destination.display()))?
|
||||
.len();
|
||||
let bytes = file_len_if_exists(destination)?
|
||||
.ok_or_else(|| format!("下载完成后目标文件缺失 {}", destination.display()))?;
|
||||
|
||||
Ok(PullOneResult {
|
||||
bytes,
|
||||
@@ -890,18 +895,11 @@ impl OfficialResourcePullService {
|
||||
}
|
||||
|
||||
fn read_download_manifest(&self) -> Result<OfficialDownloadManifest, String> {
|
||||
self.ensure_output_root_safe()?;
|
||||
let path = self.download_manifest_path();
|
||||
let bytes = match fs::read(&path) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
return Ok(OfficialDownloadManifest::default());
|
||||
}
|
||||
Err(error) => {
|
||||
return Err(format!(
|
||||
"读取下载 manifest 失败 {}:{error}",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
ensure_safe_file_target(&self.output_root, &path, "下载 manifest")?;
|
||||
let Some(bytes) = read_file_no_symlink(&path, "下载 manifest")? else {
|
||||
return Ok(OfficialDownloadManifest::default());
|
||||
};
|
||||
|
||||
let manifest: OfficialDownloadManifest = serde_json::from_slice(&bytes)
|
||||
@@ -919,29 +917,20 @@ impl OfficialResourcePullService {
|
||||
}
|
||||
|
||||
fn write_download_manifest(&self, manifest: &OfficialDownloadManifest) -> Result<(), String> {
|
||||
self.ensure_output_root_ready()?;
|
||||
let path = self.download_manifest_path();
|
||||
if let Some(parent) = path.parent() {
|
||||
ensure_safe_directory_path(parent, "下载 manifest 目录")?;
|
||||
fs::create_dir_all(parent).map_err(|error| {
|
||||
format!("创建下载 manifest 目录失败 {}:{error}", parent.display())
|
||||
})?;
|
||||
ensure_safe_directory_path(parent, "下载 manifest 目录")?;
|
||||
}
|
||||
ensure_safe_file_target(&self.output_root, &path, "下载 manifest")?;
|
||||
|
||||
let temporary = path.with_extension("json.tmp");
|
||||
let bytes = serde_json::to_vec_pretty(manifest)
|
||||
.map_err(|error| format!("序列化下载 manifest 失败 {}:{error}", path.display()))?;
|
||||
fs::write(&temporary, bytes).map_err(|error| {
|
||||
format!(
|
||||
"写入临时下载 manifest 失败 {}:{error}",
|
||||
temporary.display()
|
||||
)
|
||||
})?;
|
||||
fs::rename(&temporary, &path).map_err(|error| {
|
||||
format!(
|
||||
"移动下载 manifest 失败 {} -> {}:{error}",
|
||||
temporary.display(),
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
write_file_atomic(&path, &bytes, STATE_FILE_MODE, "下载 manifest")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -995,9 +984,8 @@ impl OfficialResourcePullService {
|
||||
destination: &Path,
|
||||
) -> Result<(), String> {
|
||||
self.validate_zip_if_needed(url, destination)?;
|
||||
let bytes = fs::metadata(destination)
|
||||
.map_err(|error| format!("读取已下载文件信息失败 {}:{error}", destination.display()))?
|
||||
.len();
|
||||
let bytes = file_len_if_exists(destination)?
|
||||
.ok_or_else(|| format!("读取已下载文件信息失败 {}", destination.display()))?;
|
||||
let digest = blake3_file_hex(destination)?;
|
||||
let relative_destination = self.relative_destination(destination)?;
|
||||
|
||||
@@ -1015,6 +1003,7 @@ impl OfficialResourcePullService {
|
||||
}
|
||||
|
||||
fn validate_zip_if_needed(&self, url: &str, destination: &Path) -> Result<(), String> {
|
||||
ensure_safe_file_target(&self.output_root, destination, "ZIP 校验目标")?;
|
||||
if !url_or_path_has_zip_extension(url) && !path_has_zip_extension(destination) {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -1171,6 +1160,7 @@ impl OfficialResourcePullService {
|
||||
}
|
||||
|
||||
fn destination_for_url(&self, url: &str) -> Result<PathBuf, String> {
|
||||
self.ensure_output_root_safe()?;
|
||||
if !is_official_yostar_jp_url(url) {
|
||||
return Err(format!("URL 不是官方 JP host:{url}"));
|
||||
}
|
||||
@@ -1182,7 +1172,7 @@ impl OfficialResourcePullService {
|
||||
.split_once('/')
|
||||
.ok_or_else(|| format!("官方 URL 缺少路径:{url}"))?;
|
||||
|
||||
let mut destination = PathBuf::from(sanitize_segment(host));
|
||||
let mut relative_destination = PathBuf::from(sanitize_segment(host));
|
||||
for segment in path.split('/') {
|
||||
if segment.is_empty() {
|
||||
continue;
|
||||
@@ -1195,10 +1185,28 @@ impl OfficialResourcePullService {
|
||||
if segment.is_empty() {
|
||||
continue;
|
||||
}
|
||||
destination.push(sanitize_segment(segment));
|
||||
relative_destination.push(sanitize_segment(segment));
|
||||
}
|
||||
|
||||
Ok(self.output_root.join(destination))
|
||||
let destination = self.output_root.join(relative_destination);
|
||||
ensure_path_within_root(&self.output_root, &destination)?;
|
||||
Ok(destination)
|
||||
}
|
||||
|
||||
fn ensure_output_root_safe(&self) -> Result<(), String> {
|
||||
validate_output_root(&self.output_root)
|
||||
}
|
||||
|
||||
fn ensure_output_root_ready(&self) -> Result<(), String> {
|
||||
self.ensure_output_root_safe()?;
|
||||
ensure_safe_directory_path(&self.output_root, "资源输出目录")?;
|
||||
fs::create_dir_all(&self.output_root).map_err(|error| {
|
||||
format!(
|
||||
"创建资源输出目录失败 {}:{error}",
|
||||
self.output_root.display()
|
||||
)
|
||||
})?;
|
||||
ensure_safe_directory_path(&self.output_root, "资源输出目录")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1345,7 +1353,10 @@ struct PullOneResult {
|
||||
}
|
||||
|
||||
fn file_len_if_exists(path: &Path) -> Result<Option<u64>, String> {
|
||||
match fs::metadata(path) {
|
||||
match fs::symlink_metadata(path) {
|
||||
Ok(metadata) if metadata.file_type().is_symlink() => {
|
||||
Err(format!("拒绝跟随 symlink 文件:{}", path.display()))
|
||||
}
|
||||
Ok(metadata) if metadata.is_file() => Ok(Some(metadata.len())),
|
||||
Ok(_) => Err(format!("期望文件路径,但实际不是文件:{}", path.display())),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||
@@ -1360,6 +1371,14 @@ fn partial_path_for(destination: &Path) -> PathBuf {
|
||||
}
|
||||
|
||||
fn blake3_file_hex(path: &Path) -> Result<String, String> {
|
||||
match fs::symlink_metadata(path) {
|
||||
Ok(metadata) if metadata.file_type().is_symlink() => {
|
||||
return Err(format!("拒绝读取 symlink 文件:{}", path.display()));
|
||||
}
|
||||
Ok(metadata) if metadata.is_file() => {}
|
||||
Ok(_) => return Err(format!("期望文件路径,但实际不是文件:{}", path.display())),
|
||||
Err(error) => return Err(format!("读取路径信息失败 {}:{error}", path.display())),
|
||||
}
|
||||
let mut file =
|
||||
File::open(path).map_err(|error| format!("打开文件失败 {}:{error}", path.display()))?;
|
||||
let mut hasher = blake3::Hasher::new();
|
||||
@@ -1468,7 +1487,8 @@ fn sanitize_segment(segment: &str) -> String {
|
||||
segment
|
||||
.chars()
|
||||
.map(|ch| {
|
||||
if matches!(ch, ':' | '*' | '?' | '"' | '<' | '>' | '|') {
|
||||
if ch.is_control() || matches!(ch, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|')
|
||||
{
|
||||
'_'
|
||||
} else {
|
||||
ch
|
||||
@@ -1874,6 +1894,81 @@ fi
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_dangerous_output_root() {
|
||||
let service = OfficialResourcePullService::new(PathBuf::from("/"));
|
||||
let error = service
|
||||
.destination_for_url(
|
||||
"https://prod-clientpatch.bluearchiveyostar.com/r93_token/TableBundles/TableCatalog.bytes",
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert!(error.contains("危险路径"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn pull_rejects_symlink_parent_escape() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let temp = TempDir::new().unwrap();
|
||||
let out_dir = temp.path().join("out");
|
||||
let escape_dir = temp.path().join("escape");
|
||||
let bin_dir = TempDir::new().unwrap();
|
||||
fs::create_dir_all(&out_dir).unwrap();
|
||||
fs::create_dir_all(&escape_dir).unwrap();
|
||||
let curl_path = bin_dir.path().join("curl");
|
||||
write_fake_curl(&curl_path);
|
||||
|
||||
let service = OfficialResourcePullService::with_curl_command(&out_dir, &curl_path);
|
||||
let destination = service
|
||||
.destination_for_url(
|
||||
"https://prod-clientpatch.bluearchiveyostar.com/r93_token/TableBundles/TableCatalog.bytes",
|
||||
)
|
||||
.unwrap();
|
||||
let host_dir = destination
|
||||
.ancestors()
|
||||
.find(|path| {
|
||||
path.file_name().and_then(|name| name.to_str())
|
||||
== Some("prod-clientpatch.bluearchiveyostar.com")
|
||||
})
|
||||
.unwrap()
|
||||
.to_path_buf();
|
||||
fs::create_dir_all(&host_dir).unwrap();
|
||||
symlink(&escape_dir, host_dir.join("r93_token")).unwrap();
|
||||
|
||||
let error = service
|
||||
.pull(&build_official_pull_plan(discovery_plan(), inventory()))
|
||||
.unwrap_err();
|
||||
assert!(error.contains("symlink"));
|
||||
assert!(fs::read_dir(&escape_dir).unwrap().next().is_none());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn pull_rejects_symlink_partial_file() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let temp = TempDir::new().unwrap();
|
||||
let out_dir = temp.path().join("out");
|
||||
let escape_file = temp.path().join("escape.txt");
|
||||
let bin_dir = TempDir::new().unwrap();
|
||||
fs::create_dir_all(&out_dir).unwrap();
|
||||
fs::write(&escape_file, b"outside").unwrap();
|
||||
let curl_path = bin_dir.path().join("curl");
|
||||
write_fake_curl(&curl_path);
|
||||
|
||||
let service = OfficialResourcePullService::with_curl_command(&out_dir, &curl_path);
|
||||
let url = "https://prod-clientpatch.bluearchiveyostar.com/r93_token/TableBundles/TableCatalog.bytes";
|
||||
let destination = service.destination_for_url(url).unwrap();
|
||||
fs::create_dir_all(destination.parent().unwrap()).unwrap();
|
||||
symlink(&escape_file, partial_path_for(&destination)).unwrap();
|
||||
|
||||
let error = service.pull_one(url, &destination).unwrap_err();
|
||||
assert!(error.contains("symlink"));
|
||||
assert_eq!(fs::read(&escape_file).unwrap(), b"outside");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_manifest_audit_detects_and_repairs_corrupted_file() {
|
||||
let out_dir = TempDir::new().unwrap();
|
||||
|
||||
@@ -5,6 +5,10 @@
|
||||
//! callers: discover current official state, compare remote markers, audit the
|
||||
//! local manifest, repair/download when needed, then return a structured report.
|
||||
|
||||
use crate::path_security::{
|
||||
ensure_path_within_root, ensure_safe_directory_path, ensure_safe_file_target,
|
||||
read_file_no_symlink, validate_output_root, write_file_atomic, STATE_FILE_MODE,
|
||||
};
|
||||
use crate::{
|
||||
build_official_pull_plan_for_platform_inventory, build_official_sync_plan,
|
||||
changed_endpoint_urls, default_official_platforms, OfficialGameMainConfigBootstrapService,
|
||||
@@ -24,6 +28,8 @@ use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::fs::{self, OpenOptions};
|
||||
use std::io::Write;
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Current official update snapshot schema version.
|
||||
@@ -495,6 +501,7 @@ impl OfficialUpdateService {
|
||||
),
|
||||
));
|
||||
check_shutdown_requested(&mut should_cancel)?;
|
||||
validate_update_paths(config).map_err(anyhow::Error::msg)?;
|
||||
|
||||
let _lock = if config.dry_run {
|
||||
progress(OfficialUpdateProgress::new("lock", "试运行:跳过状态锁"));
|
||||
@@ -1120,13 +1127,28 @@ pub fn diff_extended_snapshot(
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_update_paths(config: &OfficialUpdateConfig) -> Result<(), String> {
|
||||
validate_output_root(&config.output_root)?;
|
||||
ensure_safe_directory_path(&config.output_root, "资源输出目录")?;
|
||||
if let Some(snapshot_path) = config.snapshot_path.as_ref() {
|
||||
ensure_path_within_root(&config.output_root, snapshot_path)?;
|
||||
ensure_safe_file_target(&config.output_root, snapshot_path, "官方更新快照")?;
|
||||
}
|
||||
ensure_safe_file_target(
|
||||
&config.output_root,
|
||||
&config.bootstrap_cache_path(),
|
||||
"官方启动缓存",
|
||||
)?;
|
||||
ensure_safe_file_target(&config.output_root, &config.lock_path(), "官方同步锁")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Reads an official update snapshot from disk, accepting legacy v1 snapshots.
|
||||
pub fn read_snapshot(path: &Path) -> anyhow::Result<Option<OfficialUpdateSnapshot>> {
|
||||
if !path.exists() {
|
||||
let Some(data) = read_file_no_symlink(path, "官方更新快照").map_err(anyhow::Error::msg)?
|
||||
else {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let data = fs::read(path)?;
|
||||
};
|
||||
match serde_json::from_slice::<OfficialUpdateSnapshot>(&data) {
|
||||
Ok(snapshot) => Ok(Some(snapshot)),
|
||||
Err(update_error) => {
|
||||
@@ -1143,33 +1165,24 @@ pub fn read_snapshot(path: &Path) -> anyhow::Result<Option<OfficialUpdateSnapsho
|
||||
|
||||
/// Writes an official update snapshot to disk.
|
||||
pub fn write_snapshot(path: &Path, snapshot: &OfficialUpdateSnapshot) -> anyhow::Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
let data = serde_json::to_vec_pretty(snapshot)?;
|
||||
fs::write(path, data)?;
|
||||
write_file_atomic(path, &data, STATE_FILE_MODE, "官方更新快照").map_err(anyhow::Error::msg)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Reads an official bootstrap cache from disk.
|
||||
pub fn read_bootstrap_cache(path: &Path) -> anyhow::Result<Option<OfficialBootstrapCache>> {
|
||||
if !path.exists() {
|
||||
let Some(data) = read_file_no_symlink(path, "官方启动缓存").map_err(anyhow::Error::msg)?
|
||||
else {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let data = fs::read(path)?;
|
||||
};
|
||||
Ok(Some(serde_json::from_slice(&data)?))
|
||||
}
|
||||
|
||||
/// Writes an official bootstrap cache to disk.
|
||||
pub fn write_bootstrap_cache(path: &Path, cache: &OfficialBootstrapCache) -> anyhow::Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
let data = serde_json::to_vec_pretty(cache)?;
|
||||
fs::write(path, data)?;
|
||||
write_file_atomic(path, &data, STATE_FILE_MODE, "官方启动缓存").map_err(anyhow::Error::msg)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1445,10 +1458,21 @@ struct OfficialUpdateLock {
|
||||
|
||||
impl OfficialUpdateLock {
|
||||
fn acquire(config: &OfficialUpdateConfig) -> anyhow::Result<Self> {
|
||||
validate_output_root(&config.output_root).map_err(anyhow::Error::msg)?;
|
||||
ensure_safe_directory_path(&config.output_root, "资源输出目录")
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
fs::create_dir_all(&config.output_root)?;
|
||||
ensure_safe_directory_path(&config.output_root, "资源输出目录")
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
let path = config.lock_path();
|
||||
ensure_safe_file_target(&config.output_root, &path, "官方同步锁")
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
for attempt in 0..=1 {
|
||||
match OpenOptions::new().write(true).create_new(true).open(&path) {
|
||||
let mut options = OpenOptions::new();
|
||||
options.write(true).create_new(true);
|
||||
#[cfg(unix)]
|
||||
options.mode(crate::path_security::PRIVATE_FILE_MODE);
|
||||
match options.open(&path) {
|
||||
Ok(mut file) => {
|
||||
let pid = std::process::id().to_string();
|
||||
file.write_all(pid.as_bytes())?;
|
||||
@@ -1483,6 +1507,12 @@ impl OfficialUpdateLock {
|
||||
impl Drop for OfficialUpdateLock {
|
||||
fn drop(&mut self) {
|
||||
let expected = std::process::id().to_string();
|
||||
if fs::symlink_metadata(&self.path)
|
||||
.map(|metadata| metadata.file_type().is_symlink())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if fs::read_to_string(&self.path)
|
||||
.map(|contents| contents.trim() == expected)
|
||||
.unwrap_or(false)
|
||||
@@ -1493,6 +1523,15 @@ impl Drop for OfficialUpdateLock {
|
||||
}
|
||||
|
||||
fn remove_stale_lock(path: &Path) -> anyhow::Result<bool> {
|
||||
let metadata = match fs::symlink_metadata(path) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
if metadata.file_type().is_symlink() {
|
||||
fs::remove_file(path)?;
|
||||
return Ok(true);
|
||||
}
|
||||
let contents = fs::read_to_string(path).unwrap_or_default();
|
||||
let Some(pid) = parse_lock_pid(&contents) else {
|
||||
fs::remove_file(path)?;
|
||||
@@ -1507,6 +1546,12 @@ fn remove_stale_lock(path: &Path) -> anyhow::Result<bool> {
|
||||
}
|
||||
|
||||
fn describe_lock_owner(path: &Path) -> String {
|
||||
if fs::symlink_metadata(path)
|
||||
.map(|metadata| metadata.file_type().is_symlink())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return "锁文件是 symlink,可执行 clean-stable 清理".to_string();
|
||||
}
|
||||
let contents = fs::read_to_string(path).unwrap_or_default();
|
||||
match parse_lock_pid(&contents) {
|
||||
Some(pid) if process_exists(pid) => format!("owner_pid={pid} 仍在运行"),
|
||||
@@ -1668,6 +1713,49 @@ mod tests {
|
||||
assert_eq!(read_bootstrap_cache(&path).unwrap(), Some(cache));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_rejects_dangerous_output_root_before_network_work() {
|
||||
let config = OfficialUpdateConfig {
|
||||
output_root: PathBuf::from("/"),
|
||||
dry_run: true,
|
||||
..OfficialUpdateConfig::default()
|
||||
};
|
||||
|
||||
let error = OfficialUpdateService::new().run(&config).unwrap_err();
|
||||
assert!(error.to_string().contains("危险路径"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_rejects_snapshot_path_escape() {
|
||||
let temp = tempfile::TempDir::new().unwrap();
|
||||
let config = OfficialUpdateConfig {
|
||||
output_root: temp.path().join("resources"),
|
||||
snapshot_path: Some(temp.path().join("outside-snapshot.json")),
|
||||
dry_run: true,
|
||||
..OfficialUpdateConfig::default()
|
||||
};
|
||||
|
||||
let error = OfficialUpdateService::new().run(&config).unwrap_err();
|
||||
assert!(error.to_string().contains("路径逃逸"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn write_snapshot_rejects_symlink_target() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let temp = tempfile::TempDir::new().unwrap();
|
||||
let outside = temp.path().join("outside.json");
|
||||
let link = temp.path().join("snapshot.json");
|
||||
fs::write(&outside, b"outside").unwrap();
|
||||
symlink(&outside, &link).unwrap();
|
||||
let snapshot = OfficialUpdateSnapshot::new(fixture_base_snapshot(), Vec::new(), None);
|
||||
|
||||
let error = write_snapshot(&link, &snapshot).unwrap_err();
|
||||
assert!(error.to_string().contains("symlink"));
|
||||
assert_eq!(fs::read(&outside).unwrap(), b"outside");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bootstrap_cache_hit_requires_same_metadata_and_version() {
|
||||
let bootstrap = fixture_bootstrap();
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
//! 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())),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user