mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-07-22 08:26:44 +08:00
feat: validate official zip structure
This commit is contained in:
@@ -1,6 +1,9 @@
|
||||
//! Official JP resource download execution.
|
||||
|
||||
use crate::official_pull::OfficialResourcePullPlan;
|
||||
use crate::zip_validation::{
|
||||
path_has_zip_extension, url_or_path_has_zip_extension, validate_zip_structure,
|
||||
};
|
||||
use bat_adapters::official::yostar_jp::{is_official_yostar_jp_url, YostarJpResourceEndpointKind};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
@@ -219,6 +222,9 @@ pub enum OfficialLocalManifestAuditStatus {
|
||||
SizeMismatch,
|
||||
/// The local file BLAKE3 differs from the manifest entry.
|
||||
Blake3Mismatch,
|
||||
/// The local file has a `.zip` URL or destination but its ZIP structure is
|
||||
/// invalid.
|
||||
ZipStructureInvalid,
|
||||
}
|
||||
|
||||
impl OfficialLocalManifestAuditStatus {
|
||||
@@ -237,6 +243,7 @@ impl OfficialLocalManifestAuditStatus {
|
||||
Self::MissingFile => "missing_file",
|
||||
Self::SizeMismatch => "size_mismatch",
|
||||
Self::Blake3Mismatch => "blake3_mismatch",
|
||||
Self::ZipStructureInvalid => "zip_structure_invalid",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -260,6 +267,9 @@ pub struct OfficialLocalManifestAuditItem {
|
||||
pub expected_blake3: Option<String>,
|
||||
/// Actual local BLAKE3, when it could be computed.
|
||||
pub actual_blake3: Option<String>,
|
||||
/// ZIP structure validation error, when the URL or destination is a ZIP
|
||||
/// and the local file did not pass ZIP structure validation.
|
||||
pub zip_error: Option<String>,
|
||||
}
|
||||
|
||||
/// Local download-manifest audit result for a pull plan.
|
||||
@@ -636,6 +646,11 @@ impl OfficialResourcePullService {
|
||||
let partial = partial_path_for(destination);
|
||||
let partial_len_before = file_len_if_exists(&partial)?.unwrap_or_default();
|
||||
let resumed = partial_len_before > 0;
|
||||
let mut status = if resumed {
|
||||
OfficialResourcePullStatus::Resumed
|
||||
} else {
|
||||
OfficialResourcePullStatus::Downloaded
|
||||
};
|
||||
|
||||
if resumed {
|
||||
if let Err(error) = self.download_one(url, &partial, true) {
|
||||
@@ -644,10 +659,22 @@ impl OfficialResourcePullService {
|
||||
.map_err(|retry_error| {
|
||||
format!("续传失败 {url}:{error};清理后重新下载也失败:{retry_error}")
|
||||
})?;
|
||||
status = OfficialResourcePullStatus::Downloaded;
|
||||
} else if let Err(error) = self.validate_zip_if_needed(url, &partial) {
|
||||
let _ = fs::remove_file(&partial);
|
||||
self.download_one(url, &partial, false)
|
||||
.map_err(|retry_error| {
|
||||
format!("续传后的 ZIP 结构校验失败 {url}:{error};清理后重新下载也失败:{retry_error}")
|
||||
})?;
|
||||
status = OfficialResourcePullStatus::Downloaded;
|
||||
}
|
||||
} else {
|
||||
self.download_one(url, &partial, false)?;
|
||||
}
|
||||
if let Err(error) = self.validate_zip_if_needed(url, &partial) {
|
||||
let _ = fs::remove_file(&partial);
|
||||
return Err(error);
|
||||
}
|
||||
|
||||
if file_len_if_exists(destination)?.is_some() {
|
||||
fs::remove_file(destination).map_err(|error| {
|
||||
@@ -669,12 +696,12 @@ impl OfficialResourcePullService {
|
||||
|
||||
Ok(PullOneResult {
|
||||
bytes,
|
||||
transferred_bytes: bytes.saturating_sub(partial_len_before),
|
||||
status: if resumed {
|
||||
OfficialResourcePullStatus::Resumed
|
||||
transferred_bytes: if status == OfficialResourcePullStatus::Resumed {
|
||||
bytes.saturating_sub(partial_len_before)
|
||||
} else {
|
||||
OfficialResourcePullStatus::Downloaded
|
||||
bytes
|
||||
},
|
||||
status,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -914,6 +941,10 @@ impl OfficialResourcePullService {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
if let Err(_error) = self.validate_zip_if_needed(url, destination) {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
Ok(Some(PullOneResult {
|
||||
bytes,
|
||||
transferred_bytes: 0,
|
||||
@@ -927,6 +958,7 @@ impl OfficialResourcePullService {
|
||||
url: &str,
|
||||
destination: &Path,
|
||||
) -> Result<(), String> {
|
||||
self.validate_zip_if_needed(url, destination)?;
|
||||
let bytes = fs::metadata(destination)
|
||||
.map_err(|error| format!("读取已下载文件信息失败 {}:{error}", destination.display()))?
|
||||
.len();
|
||||
@@ -946,6 +978,14 @@ impl OfficialResourcePullService {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_zip_if_needed(&self, url: &str, destination: &Path) -> Result<(), String> {
|
||||
if !url_or_path_has_zip_extension(url) && !path_has_zip_extension(destination) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
validate_zip_structure(destination).map(|_report| ())
|
||||
}
|
||||
|
||||
fn relative_destination(&self, destination: &Path) -> Result<String, String> {
|
||||
let relative = destination
|
||||
.strip_prefix(&self.output_root)
|
||||
@@ -977,6 +1017,7 @@ impl OfficialResourcePullService {
|
||||
actual_bytes,
|
||||
expected_blake3: None,
|
||||
actual_blake3: None,
|
||||
zip_error: None,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -994,6 +1035,7 @@ impl OfficialResourcePullService {
|
||||
actual_bytes: None,
|
||||
expected_blake3,
|
||||
actual_blake3: None,
|
||||
zip_error: None,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1008,6 +1050,7 @@ impl OfficialResourcePullService {
|
||||
actual_bytes: None,
|
||||
expected_blake3,
|
||||
actual_blake3: None,
|
||||
zip_error: None,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1021,6 +1064,7 @@ impl OfficialResourcePullService {
|
||||
actual_bytes: None,
|
||||
expected_blake3,
|
||||
actual_blake3: None,
|
||||
zip_error: None,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1034,6 +1078,7 @@ impl OfficialResourcePullService {
|
||||
actual_bytes: Some(actual_bytes),
|
||||
expected_blake3,
|
||||
actual_blake3: None,
|
||||
zip_error: None,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1048,6 +1093,21 @@ impl OfficialResourcePullService {
|
||||
actual_bytes: Some(actual_bytes),
|
||||
expected_blake3,
|
||||
actual_blake3: Some(actual_blake3),
|
||||
zip_error: None,
|
||||
});
|
||||
}
|
||||
|
||||
if let Err(error) = self.validate_zip_if_needed(url, &destination) {
|
||||
return Ok(OfficialLocalManifestAuditItem {
|
||||
url: url.to_string(),
|
||||
destination,
|
||||
status: OfficialLocalManifestAuditStatus::ZipStructureInvalid,
|
||||
manifest_destination,
|
||||
expected_bytes,
|
||||
actual_bytes: Some(actual_bytes),
|
||||
expected_blake3,
|
||||
actual_blake3: Some(actual_blake3),
|
||||
zip_error: Some(error),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1060,6 +1120,7 @@ impl OfficialResourcePullService {
|
||||
actual_bytes: Some(actual_bytes),
|
||||
expected_blake3,
|
||||
actual_blake3: Some(actual_blake3),
|
||||
zip_error: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1444,7 +1505,7 @@ while [ "$#" -gt 0 ]; do
|
||||
;;
|
||||
esac
|
||||
done
|
||||
if [ -n "$out" ]; then
|
||||
if [ -n "$out" ]; then
|
||||
mkdir -p "$(dirname "$out")"
|
||||
case "$url" in
|
||||
*/TableBundles/TableCatalog.hash)
|
||||
@@ -1477,6 +1538,9 @@ if [ -n "$out" ]; then
|
||||
*/MediaResources/Catalog/MediaCatalog.bytes)
|
||||
printf '%s' 'JP_Airi_Android.zip' > "$out"
|
||||
;;
|
||||
*.zip)
|
||||
printf '\120\113\003\004\024\000\000\000\000\000\000\000\000\000\000\000\000\000\002\000\000\000\002\000\000\000\010\000\000\000file.txtok\120\113\001\002\024\000\024\000\000\000\000\000\000\000\000\000\000\000\000\000\002\000\000\000\002\000\000\000\010\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000file.txt\120\113\005\006\000\000\000\000\001\000\001\000\066\000\000\000\050\000\000\000\000\000' > "$out"
|
||||
;;
|
||||
*)
|
||||
printf '%s' "$url" > "$out"
|
||||
;;
|
||||
@@ -1610,6 +1674,35 @@ fi
|
||||
"#
|
||||
}
|
||||
|
||||
fn fake_bad_zip_curl_script() -> &'static str {
|
||||
r#"#!/bin/sh
|
||||
set -eu
|
||||
out=""
|
||||
url=""
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--output)
|
||||
out="$2"
|
||||
shift 2
|
||||
;;
|
||||
--url)
|
||||
url="$2"
|
||||
shift 2
|
||||
;;
|
||||
*)
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
if [ -n "$out" ]; then
|
||||
mkdir -p "$(dirname "$out")"
|
||||
printf '%s' 'not a zip archive' > "$out"
|
||||
else
|
||||
printf '%s' "$url"
|
||||
fi
|
||||
"#
|
||||
}
|
||||
|
||||
fn write_shell_script(path: &Path, script: &str) {
|
||||
fs::write(path, script).unwrap();
|
||||
#[cfg(unix)]
|
||||
@@ -1637,6 +1730,58 @@ fi
|
||||
write_shell_script(path, fake_bad_hash_curl_script());
|
||||
}
|
||||
|
||||
fn write_fake_bad_zip_curl(path: &Path) {
|
||||
write_shell_script(path, fake_bad_zip_curl_script());
|
||||
}
|
||||
|
||||
fn one_file_zip(name: &[u8], data: &[u8]) -> Vec<u8> {
|
||||
let mut bytes = Vec::new();
|
||||
bytes.extend_from_slice(&0x0403_4b50u32.to_le_bytes());
|
||||
bytes.extend_from_slice(&20u16.to_le_bytes());
|
||||
bytes.extend_from_slice(&0u16.to_le_bytes());
|
||||
bytes.extend_from_slice(&0u16.to_le_bytes());
|
||||
bytes.extend_from_slice(&0u16.to_le_bytes());
|
||||
bytes.extend_from_slice(&0u16.to_le_bytes());
|
||||
bytes.extend_from_slice(&0u32.to_le_bytes());
|
||||
bytes.extend_from_slice(&(data.len() as u32).to_le_bytes());
|
||||
bytes.extend_from_slice(&(data.len() as u32).to_le_bytes());
|
||||
bytes.extend_from_slice(&(name.len() as u16).to_le_bytes());
|
||||
bytes.extend_from_slice(&0u16.to_le_bytes());
|
||||
bytes.extend_from_slice(name);
|
||||
bytes.extend_from_slice(data);
|
||||
|
||||
let central_offset = bytes.len() as u32;
|
||||
bytes.extend_from_slice(&0x0201_4b50u32.to_le_bytes());
|
||||
bytes.extend_from_slice(&20u16.to_le_bytes());
|
||||
bytes.extend_from_slice(&20u16.to_le_bytes());
|
||||
bytes.extend_from_slice(&0u16.to_le_bytes());
|
||||
bytes.extend_from_slice(&0u16.to_le_bytes());
|
||||
bytes.extend_from_slice(&0u16.to_le_bytes());
|
||||
bytes.extend_from_slice(&0u16.to_le_bytes());
|
||||
bytes.extend_from_slice(&0u32.to_le_bytes());
|
||||
bytes.extend_from_slice(&(data.len() as u32).to_le_bytes());
|
||||
bytes.extend_from_slice(&(data.len() as u32).to_le_bytes());
|
||||
bytes.extend_from_slice(&(name.len() as u16).to_le_bytes());
|
||||
bytes.extend_from_slice(&0u16.to_le_bytes());
|
||||
bytes.extend_from_slice(&0u16.to_le_bytes());
|
||||
bytes.extend_from_slice(&0u16.to_le_bytes());
|
||||
bytes.extend_from_slice(&0u16.to_le_bytes());
|
||||
bytes.extend_from_slice(&0u32.to_le_bytes());
|
||||
bytes.extend_from_slice(&0u32.to_le_bytes());
|
||||
bytes.extend_from_slice(name);
|
||||
let central_size = bytes.len() as u32 - central_offset;
|
||||
|
||||
bytes.extend_from_slice(&0x0605_4b50u32.to_le_bytes());
|
||||
bytes.extend_from_slice(&0u16.to_le_bytes());
|
||||
bytes.extend_from_slice(&0u16.to_le_bytes());
|
||||
bytes.extend_from_slice(&1u16.to_le_bytes());
|
||||
bytes.extend_from_slice(&1u16.to_le_bytes());
|
||||
bytes.extend_from_slice(¢ral_size.to_le_bytes());
|
||||
bytes.extend_from_slice(¢ral_offset.to_le_bytes());
|
||||
bytes.extend_from_slice(&0u16.to_le_bytes());
|
||||
bytes
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn downloads_verified_pull_plan_to_disk() {
|
||||
let out_dir = TempDir::new().unwrap();
|
||||
@@ -1764,11 +1909,13 @@ fi
|
||||
let destination = service.destination_for_url(&url).unwrap();
|
||||
fs::create_dir_all(destination.parent().unwrap()).unwrap();
|
||||
let bytes = if url.ends_with("/TableBundles/TableCatalog.bytes") {
|
||||
b"ExcelDB.db".as_slice()
|
||||
b"ExcelDB.db".to_vec()
|
||||
} else if url.ends_with("/TableBundles/TableCatalog.hash") {
|
||||
b"3223500437".as_slice()
|
||||
b"3223500437".to_vec()
|
||||
} else if url.ends_with(".zip") {
|
||||
one_file_zip(b"fixture.txt", b"ok")
|
||||
} else {
|
||||
url.as_bytes()
|
||||
url.as_bytes().to_vec()
|
||||
};
|
||||
fs::write(&destination, bytes).unwrap();
|
||||
service
|
||||
@@ -1955,6 +2102,99 @@ fi
|
||||
assert_eq!(xxhash32(b"JP_Airi_Win.zip"), 876690720);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_downloaded_zip_when_structure_is_invalid() {
|
||||
let out_dir = TempDir::new().unwrap();
|
||||
let bin_dir = TempDir::new().unwrap();
|
||||
let curl_path = bin_dir.path().join("curl");
|
||||
write_fake_bad_zip_curl(&curl_path);
|
||||
|
||||
let service = OfficialResourcePullService::with_curl_command(out_dir.path(), &curl_path);
|
||||
let zip_url =
|
||||
"https://prod-clientpatch.bluearchiveyostar.com/r93_token/Windows_PatchPack/Broken.zip";
|
||||
let plan = OfficialResourcePullPlan {
|
||||
discovery: YostarJpResourceDiscoveryPlan {
|
||||
connection_group_name: "Prod-Audit".to_string(),
|
||||
app_version: "1.70.0".to_string(),
|
||||
bundle_version: None,
|
||||
addressables_root: "https://prod-clientpatch.bluearchiveyostar.com/r93_token"
|
||||
.to_string(),
|
||||
endpoints: vec![YostarJpResourceEndpoint {
|
||||
kind: YostarJpResourceEndpointKind::AddressablesCatalog,
|
||||
platform: Some(PatchPlatform::Windows),
|
||||
url: zip_url.to_string(),
|
||||
}],
|
||||
},
|
||||
inventory: YostarJpPlatformDownloadInventory::from_shared_inventory(
|
||||
YostarJpDownloadInventory::from_catalog_bytes(b"", b"", b""),
|
||||
&[],
|
||||
),
|
||||
platforms: Vec::new(),
|
||||
};
|
||||
|
||||
let error = service.pull(&plan).unwrap_err();
|
||||
|
||||
assert!(error.contains("ZIP"));
|
||||
assert!(!partial_path_for(&service.destination_for_url(zip_url).unwrap()).exists());
|
||||
assert!(service.read_download_manifest().unwrap().entries.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_manifest_audit_rejects_zip_with_matching_size_and_blake3_but_bad_structure() {
|
||||
let out_dir = TempDir::new().unwrap();
|
||||
let service = OfficialResourcePullService::new(out_dir.path());
|
||||
let zip_url =
|
||||
"https://prod-clientpatch.bluearchiveyostar.com/r93_token/Windows_PatchPack/Broken.zip";
|
||||
let destination = service.destination_for_url(zip_url).unwrap();
|
||||
fs::create_dir_all(destination.parent().unwrap()).unwrap();
|
||||
fs::write(&destination, b"not a zip archive").unwrap();
|
||||
|
||||
let mut manifest = OfficialDownloadManifest::default();
|
||||
let bytes = fs::metadata(&destination).unwrap().len();
|
||||
let blake3 = blake3_file_hex(&destination).unwrap();
|
||||
manifest.entries.insert(
|
||||
zip_url.to_string(),
|
||||
OfficialDownloadManifestEntry {
|
||||
url: zip_url.to_string(),
|
||||
destination: service.relative_destination(&destination).unwrap(),
|
||||
bytes,
|
||||
blake3,
|
||||
},
|
||||
);
|
||||
service.write_download_manifest(&manifest).unwrap();
|
||||
|
||||
let plan = OfficialResourcePullPlan {
|
||||
discovery: YostarJpResourceDiscoveryPlan {
|
||||
connection_group_name: "Prod-Audit".to_string(),
|
||||
app_version: "1.70.0".to_string(),
|
||||
bundle_version: None,
|
||||
addressables_root: "https://prod-clientpatch.bluearchiveyostar.com/r93_token"
|
||||
.to_string(),
|
||||
endpoints: vec![YostarJpResourceEndpoint {
|
||||
kind: YostarJpResourceEndpointKind::AddressablesCatalog,
|
||||
platform: Some(PatchPlatform::Windows),
|
||||
url: zip_url.to_string(),
|
||||
}],
|
||||
},
|
||||
inventory: YostarJpPlatformDownloadInventory::from_shared_inventory(
|
||||
YostarJpDownloadInventory::from_catalog_bytes(b"", b"", b""),
|
||||
&[],
|
||||
),
|
||||
platforms: Vec::new(),
|
||||
};
|
||||
|
||||
let audit = service.audit_local_manifest(&plan).unwrap();
|
||||
let verification = service.verify_local_download_manifest().unwrap();
|
||||
|
||||
assert_eq!(
|
||||
audit.items[0].status,
|
||||
OfficialLocalManifestAuditStatus::ZipStructureInvalid
|
||||
);
|
||||
assert!(audit.items[0].zip_error.as_ref().unwrap().contains("ZIP"));
|
||||
assert!(!verification.is_clean());
|
||||
assert_eq!(verification.failure_count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resumes_partial_files_before_atomic_completion() {
|
||||
let out_dir = TempDir::new().unwrap();
|
||||
|
||||
Reference in New Issue
Block a user