From 45aba2fb2b11de09c12092db18219bc1a691f6c5 Mon Sep 17 00:00:00 2001 From: Yuyi-Oak <1722157266@qq.com> Date: Mon, 13 Jul 2026 23:17:52 +0800 Subject: [PATCH] fix: harden official sync filesystem boundaries Fixes #14 --- CHANGELOG.md | 1 + CURRENT_STATUS.md | 1 + README.md | 2 + .../architecture/official-resource-backend.md | 4 +- docs/guides/official-resource-test-pull.md | 2 + infrastructure/src/bin/bat_official_sync.rs | 253 +++++++++++--- infrastructure/src/lib.rs | 6 + infrastructure/src/official_download.rs | 179 +++++++--- infrastructure/src/official_update.rs | 126 +++++-- infrastructure/src/path_security.rs | 316 ++++++++++++++++++ 10 files changed, 788 insertions(+), 102 deletions(-) create mode 100644 infrastructure/src/path_security.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index eb6bc92..278fddb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 文件不跟随 symlink,daemon 状态文件默认使用 `0600` 权限 - 新增官方资源同步生产部署模板:release binary symlink 路径、systemd unit、运行用户、日志位置、升级和回滚流程 - 新增官方资源下载校验:官方 URL 拒绝、`.part` 续传、重试、本地 size+BLAKE3、官方 seed `.hash` 校验 - 新增 Addressables 当前真实形态 fixture/golden 测试 diff --git a/CURRENT_STATUS.md b/CURRENT_STATUS.md index 69d0233..a3084f4 100644 --- a/CURRENT_STATUS.md +++ b/CURRENT_STATUS.md @@ -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 尚未记录在仓库文档中。 diff --git a/README.md b/README.md index 148da53..7c84925 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,8 @@ cargo run -p bat-infrastructure --bin bat -- stop `status`、`stop`、`logs`、`reload` 和默认形态的 `refresh` 会优先连接 live RPC socket;socket 不可用时,状态和停止命令会回退到 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 diff --git a/docs/architecture/official-resource-backend.md b/docs/architecture/official-resource-backend.md index 0936883..f5efc39 100644 --- a/docs/architecture/official-resource-backend.md +++ b/docs/architecture/official-resource-backend.md @@ -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 调单次模式只是可选集成方式。项目是否热更新、热重载或重启进程,由上层业务集成决定。 对应实现主要在: diff --git a/docs/guides/official-resource-test-pull.md b/docs/guides/official-resource-test-pull.md index 83de1e8..a65b24a 100644 --- a/docs/guides/official-resource-test-pull.md +++ b/docs/guides/official-resource-test-pull.md @@ -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 资源,不安装、不启动、不依赖官方启动器二进制: diff --git a/infrastructure/src/bin/bat_official_sync.rs b/infrastructure/src/bin/bat_official_sync.rs index bbb7885..5effd27 100644 --- a/infrastructure/src/bin/bat_official_sync.rs +++ b/infrastructure/src/bin/bat_official_sync.rs @@ -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 { + 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 { - 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 { + 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, ) -> anyhow::Result { 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 { + 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 { + path_exists_no_follow(socket_path) +} + +fn path_exists_no_follow(path: &Path) -> anyhow::Result { + 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, message: &'static str, ) -> anyhow::Result { + 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 anyhow::Result anyhow::Result 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::>(); let start = lines.len().saturating_sub(tail_lines); @@ -2262,6 +2333,16 @@ fn run_doctor_command(options: &CliOptions) -> anyhow::Result { &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 { } 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) -> any } fn read_daemon_status_file(path: &Path) -> anyhow::Result> { - 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> { - 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::().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(); diff --git a/infrastructure/src/lib.rs b/infrastructure/src/lib.rs index b658d90..1e598ec 100644 --- a/infrastructure/src/lib.rs +++ b/infrastructure/src/lib.rs @@ -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 版本号 diff --git a/infrastructure/src/official_download.rs b/infrastructure/src/official_download.rs index bd5e8a9..ee8da75 100644 --- a/infrastructure/src/official_download.rs +++ b/infrastructure/src/official_download.rs @@ -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 { - 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 { + 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 { + 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 { + 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, 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 { + 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(); diff --git a/infrastructure/src/official_update.rs b/infrastructure/src/official_update.rs index ee542a6..fcc2948 100644 --- a/infrastructure/src/official_update.rs +++ b/infrastructure/src/official_update.rs @@ -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> { - 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::(&data) { Ok(snapshot) => Ok(Some(snapshot)), Err(update_error) => { @@ -1143,33 +1165,24 @@ pub fn read_snapshot(path: &Path) -> anyhow::Result 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> { - 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 { + 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 { + 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 { } 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(); diff --git a/infrastructure/src/path_security.rs b/infrastructure/src/path_security.rs new file mode 100644 index 0000000..2c05827 --- /dev/null +++ b/infrastructure/src/path_security.rs @@ -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 { + let absolute = if path.is_absolute() { + path.to_path_buf() + } else { + env::current_dir() + .map_err(|error| format!("读取当前目录失败:{error}"))? + .join(path) + }; + Ok(lexically_normalize_path(&absolute)) +} + +/// Ensures `path` is lexically contained inside `root`. +pub fn ensure_path_within_root(root: &Path, path: &Path) -> Result<(), String> { + let root = lexical_absolute(root)?; + let path = lexical_absolute(path)?; + if path.starts_with(&root) { + Ok(()) + } else { + Err(format!( + "路径逃逸:{} 不在 {} 下", + path.display(), + root.display() + )) + } +} + +/// Ensures a file target and all existing ancestors are safe to write. +pub fn ensure_safe_file_target(root: &Path, path: &Path, label: &str) -> Result<(), String> { + ensure_path_within_root(root, path)?; + ensure_no_symlink_in_existing_path(path, label)?; + match fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_symlink() => { + Err(format!("{label} 不能是 symlink:{}", path.display())) + } + Ok(metadata) if metadata.is_file() => Ok(()), + Ok(_) => Err(format!("{label} 已存在但不是普通文件:{}", path.display())), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(format!("{label} 路径检查失败 {}:{error}", path.display())), + } +} + +/// Ensures a directory path and all existing ancestors are not symlinks. +pub fn ensure_safe_directory_path(path: &Path, label: &str) -> Result<(), String> { + ensure_no_symlink_in_existing_path(path, label)?; + match fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_symlink() => { + Err(format!("{label} 不能是 symlink:{}", path.display())) + } + Ok(metadata) if metadata.is_dir() => Ok(()), + Ok(_) => Err(format!("{label} 已存在但不是目录:{}", path.display())), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(format!("{label} 路径检查失败 {}:{error}", path.display())), + } +} + +/// Reads a file without following a symlink at the file path. +pub fn read_file_no_symlink(path: &Path, label: &str) -> Result>, String> { + let metadata = match fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => { + return Err(format!( + "读取 {label} 元数据失败 {}:{error}", + path.display() + )) + } + }; + if metadata.file_type().is_symlink() { + return Err(format!("{label} 不能是 symlink:{}", path.display())); + } + if !metadata.is_file() { + return Err(format!("{label} 不是普通文件:{}", path.display())); + } + fs::read(path) + .map(Some) + .map_err(|error| format!("读取 {label} 失败 {}:{error}", path.display())) +} + +/// Writes a file atomically without following symlinks and sets a Unix mode. +pub fn write_file_atomic(path: &Path, bytes: &[u8], mode: u32, label: &str) -> Result<(), String> { + let parent = path + .parent() + .ok_or_else(|| format!("{label} 缺少父目录:{}", path.display()))?; + ensure_safe_directory_path(parent, label)?; + fs::create_dir_all(parent) + .map_err(|error| format!("创建 {label} 父目录失败 {}:{error}", parent.display()))?; + ensure_safe_directory_path(parent, label)?; + + ensure_no_symlink_in_existing_path(path, label)?; + let temporary = temporary_path_for(path); + remove_existing_temporary(&temporary, label)?; + + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + options.mode(mode); + let mut file = options + .open(&temporary) + .map_err(|error| format!("创建临时 {label} 失败 {}:{error}", temporary.display()))?; + file.write_all(bytes) + .map_err(|error| format!("写入临时 {label} 失败 {}:{error}", temporary.display()))?; + file.flush() + .map_err(|error| format!("刷新临时 {label} 失败 {}:{error}", temporary.display()))?; + drop(file); + + if let Ok(metadata) = fs::symlink_metadata(path) { + if metadata.file_type().is_symlink() { + let _ = fs::remove_file(&temporary); + return Err(format!("{label} 不能是 symlink:{}", path.display())); + } + if !metadata.is_file() { + let _ = fs::remove_file(&temporary); + return Err(format!("{label} 已存在但不是普通文件:{}", path.display())); + } + } + + fs::rename(&temporary, path).map_err(|error| { + format!( + "移动临时 {label} 失败 {} -> {}:{error}", + temporary.display(), + path.display() + ) + })?; + set_file_mode(path, mode, label)?; + Ok(()) +} + +/// Opens an append-only file safely without following symlinks at the target. +pub fn open_append_file(path: &Path, mode: u32, label: &str) -> Result { + let parent = path + .parent() + .ok_or_else(|| format!("{label} 缺少父目录:{}", path.display()))?; + ensure_safe_directory_path(parent, label)?; + fs::create_dir_all(parent) + .map_err(|error| format!("创建 {label} 父目录失败 {}:{error}", parent.display()))?; + ensure_safe_directory_path(parent, label)?; + if let Ok(metadata) = fs::symlink_metadata(path) { + if metadata.file_type().is_symlink() { + return Err(format!("{label} 不能是 symlink:{}", path.display())); + } + if !metadata.is_file() { + return Err(format!("{label} 已存在但不是普通文件:{}", path.display())); + } + } + + let mut options = OpenOptions::new(); + options.create(true).append(true); + #[cfg(unix)] + options.mode(mode); + let file = options + .open(path) + .map_err(|error| format!("打开 {label} 失败 {}:{error}", path.display()))?; + set_file_mode(path, mode, label)?; + Ok(file) +} + +/// Sets a file mode on Unix and is a no-op elsewhere. +pub fn set_file_mode(path: &Path, mode: u32, label: &str) -> Result<(), String> { + #[cfg(unix)] + { + fs::set_permissions(path, fs::Permissions::from_mode(mode)) + .map_err(|error| format!("设置 {label} 权限失败 {}:{error}", path.display()))?; + } + #[cfg(not(unix))] + { + let _ = (path, mode, label); + } + Ok(()) +} + +fn validate_managed_directory(path: &Path, label: &str) -> Result<(), String> { + if path.as_os_str().is_empty() { + return Err(format!("{label} 不能为空")); + } + let absolute = lexical_absolute(path)?; + if is_dangerous_directory_root(&absolute) { + return Err(format!( + "{label} 是危险路径,不能直接使用:{}", + absolute.display() + )); + } + ensure_safe_directory_path(&absolute, label)?; + if absolute.join(".git").exists() { + return Err(format!( + "{label} 不能是 Git 工作区根目录:{}", + absolute.display() + )); + } + Ok(()) +} + +fn is_dangerous_directory_root(path: &Path) -> bool { + const DANGEROUS: &[&str] = &[ + "/", "/bin", "/boot", "/dev", "/etc", "/home", "/lib", "/lib64", "/opt", "/proc", "/root", + "/run", "/sbin", "/sys", "/tmp", "/usr", "/var", "/var/lib", "/var/run", "/var/tmp", + ]; + DANGEROUS + .iter() + .any(|candidate| path == Path::new(candidate)) +} + +fn ensure_no_symlink_in_existing_path(path: &Path, label: &str) -> Result<(), String> { + let absolute = lexical_absolute(path)?; + let mut current = PathBuf::new(); + let mut saw_missing = false; + for component in absolute.components() { + push_component(&mut current, component); + if saw_missing { + continue; + } + match fs::symlink_metadata(¤t) { + Ok(metadata) if metadata.file_type().is_symlink() => { + return Err(format!( + "{label} 路径不能经过 symlink:{}", + current.display() + )); + } + Ok(metadata) => { + if current != absolute && !metadata.is_dir() { + return Err(format!("{label} 父路径不是目录:{}", current.display())); + } + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + saw_missing = true; + } + Err(error) => { + return Err(format!( + "{label} 路径检查失败 {}:{error}", + current.display() + )); + } + } + } + Ok(()) +} + +fn push_component(path: &mut PathBuf, component: Component<'_>) { + match component { + Component::Prefix(prefix) => path.push(prefix.as_os_str()), + Component::RootDir => path.push(Path::new("/")), + Component::CurDir => {} + Component::ParentDir => path.push(".."), + Component::Normal(value) => path.push(value), + } +} + +fn lexically_normalize_path(path: &Path) -> PathBuf { + let mut normalized = PathBuf::new(); + for component in path.components() { + match component { + Component::CurDir => {} + Component::ParentDir => { + if !normalized.pop() { + normalized.push(component.as_os_str()); + } + } + _ => normalized.push(component.as_os_str()), + } + } + if normalized.as_os_str().is_empty() { + PathBuf::from(".") + } else { + normalized + } +} + +fn temporary_path_for(path: &Path) -> PathBuf { + let file_name = path + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("bat-state"); + path.with_file_name(format!(".{file_name}.{}.tmp", std::process::id())) +} + +fn remove_existing_temporary(path: &Path, label: &str) -> Result<(), String> { + match fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_symlink() => { + Err(format!("临时 {label} 不能是 symlink:{}", path.display())) + } + Ok(metadata) if metadata.is_file() => fs::remove_file(path) + .map_err(|error| format!("清理临时 {label} 失败 {}:{error}", path.display())), + Ok(_) => Err(format!( + "临时 {label} 已存在但不是普通文件:{}", + path.display() + )), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(format!("读取临时 {label} 失败 {}:{error}", path.display())), + } +}