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