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
+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();