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