fix: harden official sync filesystem boundaries

Fixes #14
This commit is contained in:
2026-07-13 23:17:52 +08:00
parent 1bdbb47b52
commit 45aba2fb2b
10 changed files with 788 additions and 102 deletions
+214 -39
View File
@@ -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();