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
+1
View File
@@ -18,6 +18,7 @@
- 新增 Rust 官方同步 binary,当前正式入口为 `bat`,支持 one-shot、`--watch`、默认 1 小时间隔、人类可读默认输出和 `--json` 结构化 report
- 官方同步正式入口统一为 `bat`,新增 `--daemon` 后台模式和基于 `bat.sock` 的 Unix socket JSON-RPC 控制面,管理命令覆盖 `status``stop``restart``reload``refresh``logs``verify``repair``doctor``clean-stable`
- 新增 daemon 控制锁 `bat-control.lock`、daemon 与前台同资源目录写入互斥,以及失效/损坏 PID 和锁文件恢复逻辑
- 新增官方同步路径安全边界:拒绝危险输出目录和 snapshot 路径逃逸,下载目标、manifest、daemon PID/status/log/control 文件不跟随 symlinkdaemon 状态文件默认使用 `0600` 权限
- 新增官方资源同步生产部署模板:release binary symlink 路径、systemd unit、运行用户、日志位置、升级和回滚流程
- 新增官方资源下载校验:官方 URL 拒绝、`.part` 续传、重试、本地 size+BLAKE3、官方 seed `.hash` 校验
- 新增 Addressables 当前真实形态 fixture/golden 测试
+1
View File
@@ -23,6 +23,7 @@ Rust 侧官方日服资源链路已经从实验验证推进到正式入口:
7. `bat --watch` 可常驻运行,`bat --daemon` 可后台运行并用 `bat status` / `bat stop` / `bat restart` / `bat reload` / `bat logs` 管理;daemon 使用 `bat.sock` Unix socket JSON-RPC 作为 live 控制通道,PID/状态/日志文件作为快照和 fallback,`bat-control.lock` 串行化控制命令;正常检查默认每 1 小时一次;远端和本地一致时默认静默,失败后默认 60 秒快速重试;CLI 默认向 stdout 输出人类可读摘要,向 stderr 输出 ASCII banner 和 progress log,需要机器输出时使用 `--json --no-progress`
8. 远端 snapshot 未变化但输出目录为空时,会按首次运行执行全量拉取;官方 seed `.hash` 校验失败时会清理对应 manifest 条目,避免失败产物被后续本地 audit 误判为可复用。
9. 默认资源目录是 `./bat-resources`,默认后台状态目录是 `/tmp/bat-pid`;该目录包含 `bat.sock``bat.pid``bat-status.json``bat-daemon.log` 和短生命周期 `bat-control.lock`;非 dry-run 使用 `--output/.official-sync.lock` 防止并发写同一资源目录,live daemon 会阻止前台写命令直接修改它正在管理的同一目录。
10. 官方同步会拒绝危险输出目录、路径逃逸和现有 symlink 路径组件;下载目标、`.part`、manifest、snapshot、PID、status、log 和控制锁文件不会跟随 symlink,daemon 状态类文件默认以 `0600` 权限创建。
仍需明确:这不是完整产品完成。Go CLI 最小入口、完整 AssetBundle 解析、Patch、翻译系统、API Server 和 Web 仍是后续工作;真实官方网络全量下载 smoke test 尚未记录在仓库文档中。
+2
View File
@@ -94,6 +94,8 @@ cargo run -p bat-infrastructure --bin bat -- stop
`status``stop``logs``reload` 和默认形态的 `refresh` 会优先连接 live RPC socketsocket 不可用时,状态和停止命令会回退到 PID/状态文件兼容路径。`reload` 不再强制重启进程,而是让后台 watch 循环重新自动发现并执行强制刷新:空闲睡眠时立即唤醒,正在同步时排队到当前轮结束后执行。确实需要替换启动参数时使用 `restart` 或给 `reload` 显式传入同步参数。后台 daemon 正在管理某个资源目录时,前台 `run/watch/refresh/repair` 不能直接写同一目录;默认形态的 `refresh` 会改走 RPC,显式参数导致无法走 RPC 时需要先 `stop`
`bat` 会拒绝危险输出目录、路径逃逸和现有 symlink 路径组件;下载目标、`.part`、manifest、snapshot、PID、status、log 和控制锁文件不会跟随 symlink,daemon 状态类文件默认以 `0600` 权限创建。
资源操作命令默认输出人类可读摘要,并在没有显式 metadata 参数时默认走官方自动发现。脚本或上层程序需要稳定结构化输出时加 `--json`
```bash
@@ -133,7 +133,7 @@
12. 记录最终文件大小、本次传输字节数、官方 hash 校验数和执行状态。
13. 非官方 URL 直接拒绝。
路径映射时会做分段清理,避免把不安全路径写进输出目录。
路径映射时会做分段清理,并在写入前做输出目录安全校验、相对路径归属校验和现有路径组件 symlink 检查,避免把不安全路径写进输出目录或通过 symlink 跳出输出目录。
对应实现主要在:
@@ -193,7 +193,7 @@
9. 远端变化、本地 audit 发现 repair_needed,或首次空目录运行时,下载并校验官方 URL。
10. 下载成功后写回新的 snapshot。
该入口不安装、不执行官方启动器,也不读取生产外的本地客户端目录。Rust 正式 binary `bat` 支持单次运行、`--watch` 常驻模式、`--daemon` 后台模式,以及 `status``stop``restart``reload``logs``refresh``verify``repair``doctor``clean-stable` 管理命令。`--daemon` 会在后台状态目录下创建 `bat.sock`,使用 Unix socket JSON-RPC 作为 live control plane`bat.pid``bat-status.json``bat-daemon.log` 是快照、诊断和兼容 fallback;`bat-control.lock` 串行化控制命令,并在 stale/corrupt 时由下一次控制命令或 `clean-stable` 恢复。`status``stop``logs``reload` 和默认形态的 `refresh` 优先走 RPC`reload` 会唤醒或排队 watch 循环重新自动发现并强制刷新,`restart` 才负责重启进程或替换启动参数。后台 daemon 管理某个资源目录时,前台 `run/watch/refresh/repair` 不允许直接写入同一目录;默认形态 `refresh` 会通过 RPC 触发后台刷新。正常情况下默认每 1 小时执行一次检查;每天北京时间(UTC+8)`03:00``16:00``18:00` 会中断普通 sleep 并强制执行一次自动刷新,该轮注入 `force=true`。远端和本地一致时静默等待下次检查,不一致时自动下载或 repair。下载、发现或校验失败时不等待完整正常周期,默认 60 秒后重试;如果固定时间强制刷新失败,会保留 pending force 并按失败重试周期继续重试,可用 `--error-retry``--error-retry-seconds` 调整。默认资源输出目录是 `./bat-resources`,默认后台状态目录是 `/tmp/bat-pid`,二者通过 `--output``--state-dir` 分别配置。单次运行仍保留为核心幂等路径,systemd service、容器或 Go 进程可以只负责守护该常驻进程;cron/systemd timer 调单次模式只是可选集成方式。项目是否热更新、热重载或重启进程,由上层业务集成决定。
该入口不安装、不执行官方启动器,也不读取生产外的本地客户端目录。Rust 正式 binary `bat` 支持单次运行、`--watch` 常驻模式、`--daemon` 后台模式,以及 `status``stop``restart``reload``logs``refresh``verify``repair``doctor``clean-stable` 管理命令。`--daemon` 会在后台状态目录下创建 `bat.sock`,使用 Unix socket JSON-RPC 作为 live control plane`bat.pid``bat-status.json``bat-daemon.log` 是快照、诊断和兼容 fallback;`bat-control.lock` 串行化控制命令,并在 stale/corrupt 时由下一次控制命令或 `clean-stable` 恢复。PID、status、log 和控制锁文件创建时使用私有权限,读取和写入时不跟随 symlink。`status``stop``logs``reload` 和默认形态的 `refresh` 优先走 RPC`reload` 会唤醒或排队 watch 循环重新自动发现并强制刷新,`restart` 才负责重启进程或替换启动参数。后台 daemon 管理某个资源目录时,前台 `run/watch/refresh/repair` 不允许直接写入同一目录;默认形态 `refresh` 会通过 RPC 触发后台刷新。正常情况下默认每 1 小时执行一次检查;每天北京时间(UTC+8)`03:00``16:00``18:00` 会中断普通 sleep 并强制执行一次自动刷新,该轮注入 `force=true`。远端和本地一致时静默等待下次检查,不一致时自动下载或 repair。下载、发现或校验失败时不等待完整正常周期,默认 60 秒后重试;如果固定时间强制刷新失败,会保留 pending force 并按失败重试周期继续重试,可用 `--error-retry``--error-retry-seconds` 调整。默认资源输出目录是 `./bat-resources`,默认后台状态目录是 `/tmp/bat-pid`,二者通过 `--output``--state-dir` 分别配置。单次运行仍保留为核心幂等路径,systemd service、容器或 Go 进程可以只负责守护该常驻进程;cron/systemd timer 调单次模式只是可选集成方式。项目是否热更新、热重载或重启进程,由上层业务集成决定。
对应实现主要在:
@@ -47,6 +47,8 @@ target/release/bat \
默认资源输出目录是 `./bat-resources`,默认后台状态目录是 `/tmp/bat-pid`。后台状态目录会保存 `bat.sock``bat.pid``bat-status.json``bat-daemon.log` 和短生命周期的 `bat-control.lock`;其中 `bat.sock` 是 live daemon 的 Unix socket JSON-RPC 控制通道,`bat-control.lock` 串行化 `status/stop/restart/reload/logs/refresh` 等控制命令,其他文件是快照和故障排查用。生产资源输出目录必须是独立目录;需要覆盖时用 `--output <资源目录>`,不要使用已有游戏客户端目录、官方启动器安装目录、人工维护资源目录,或开发机上的 `/home/wanye/D/BlueArchive`
同步流程会拒绝危险输出目录、路径逃逸和现有 symlink 路径组件;下载目标、`.part`、manifest、snapshot、PID、status、log 和控制锁文件不会跟随 symlink,daemon 状态类文件默认以 `0600` 权限创建。
## 1. 当前流程
Linux 生产运行时链路只走官方日服 HTTP 资源,不安装、不启动、不依赖官方启动器二进制:
+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();
+6
View File
@@ -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 版本号
+137 -42
View File
@@ -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();
+107 -19
View File
@@ -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();
+316
View File
@@ -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(&current) {
Ok(metadata) if metadata.file_type().is_symlink() => {
return Err(format!(
"{label} 路径不能经过 symlink{}",
current.display()
));
}
Ok(metadata) => {
if current != absolute && !metadata.is_dir() {
return Err(format!("{label} 父路径不是目录:{}", current.display()));
}
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
saw_missing = true;
}
Err(error) => {
return Err(format!(
"{label} 路径检查失败 {}{error}",
current.display()
));
}
}
}
Ok(())
}
fn push_component(path: &mut PathBuf, component: Component<'_>) {
match component {
Component::Prefix(prefix) => path.push(prefix.as_os_str()),
Component::RootDir => path.push(Path::new("/")),
Component::CurDir => {}
Component::ParentDir => path.push(".."),
Component::Normal(value) => path.push(value),
}
}
fn lexically_normalize_path(path: &Path) -> PathBuf {
let mut normalized = PathBuf::new();
for component in path.components() {
match component {
Component::CurDir => {}
Component::ParentDir => {
if !normalized.pop() {
normalized.push(component.as_os_str());
}
}
_ => normalized.push(component.as_os_str()),
}
}
if normalized.as_os_str().is_empty() {
PathBuf::from(".")
} else {
normalized
}
}
fn temporary_path_for(path: &Path) -> PathBuf {
let file_name = path
.file_name()
.and_then(|value| value.to_str())
.unwrap_or("bat-state");
path.with_file_name(format!(".{file_name}.{}.tmp", std::process::id()))
}
fn remove_existing_temporary(path: &Path, label: &str) -> Result<(), String> {
match fs::symlink_metadata(path) {
Ok(metadata) if metadata.file_type().is_symlink() => {
Err(format!("临时 {label} 不能是 symlink{}", path.display()))
}
Ok(metadata) if metadata.is_file() => fs::remove_file(path)
.map_err(|error| format!("清理临时 {label} 失败 {}{error}", path.display())),
Ok(_) => Err(format!(
"临时 {label} 已存在但不是普通文件:{}",
path.display()
)),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(format!("读取临时 {label} 失败 {}{error}", path.display())),
}
}