mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-07-23 08:55:16 +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();
|
||||
|
||||
Reference in New Issue
Block a user