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