mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-07-22 01:15:14 +08:00
397 lines
13 KiB
Rust
397 lines
13 KiB
Rust
//! Official JP resource download execution.
|
|
|
|
use crate::official_pull::OfficialResourcePullPlan;
|
|
use bat_adapters::official::yostar_jp::is_official_yostar_jp_url;
|
|
use std::fs;
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::Command;
|
|
|
|
/// One downloaded official resource.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct OfficialResourcePullItem {
|
|
/// Original URL.
|
|
pub url: String,
|
|
/// Local destination path under the output root.
|
|
pub destination: PathBuf,
|
|
/// Downloaded byte count.
|
|
pub bytes: u64,
|
|
}
|
|
|
|
/// Download report for one executed pull plan.
|
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
|
pub struct OfficialResourcePullReport {
|
|
/// Downloaded resources in execution order.
|
|
pub items: Vec<OfficialResourcePullItem>,
|
|
}
|
|
|
|
impl OfficialResourcePullReport {
|
|
/// Returns the total downloaded byte count.
|
|
pub fn total_bytes(&self) -> u64 {
|
|
self.items.iter().map(|item| item.bytes).sum()
|
|
}
|
|
}
|
|
|
|
/// Executes an official pull plan with the system `curl` command.
|
|
#[derive(Debug, Clone)]
|
|
pub struct OfficialResourcePullService {
|
|
output_root: PathBuf,
|
|
curl_command: PathBuf,
|
|
}
|
|
|
|
impl OfficialResourcePullService {
|
|
/// Creates a pull service that uses `curl` from `PATH`.
|
|
pub fn new(output_root: impl Into<PathBuf>) -> Self {
|
|
Self::with_curl_command(output_root, "curl")
|
|
}
|
|
|
|
/// Creates a pull service with an explicit `curl` binary path.
|
|
pub fn with_curl_command(
|
|
output_root: impl Into<PathBuf>,
|
|
curl_command: impl Into<PathBuf>,
|
|
) -> Self {
|
|
Self {
|
|
output_root: output_root.into(),
|
|
curl_command: curl_command.into(),
|
|
}
|
|
}
|
|
|
|
/// Returns the output root used for downloaded files.
|
|
pub fn output_root(&self) -> &Path {
|
|
&self.output_root
|
|
}
|
|
|
|
/// Executes the plan and downloads every official URL to disk.
|
|
pub fn pull(
|
|
&self,
|
|
plan: &OfficialResourcePullPlan,
|
|
) -> Result<OfficialResourcePullReport, String> {
|
|
fs::create_dir_all(&self.output_root).map_err(|error| {
|
|
format!(
|
|
"Failed to create output root {}: {error}",
|
|
self.output_root.display()
|
|
)
|
|
})?;
|
|
|
|
let mut items = Vec::new();
|
|
for url in plan.all_urls()? {
|
|
if !is_official_yostar_jp_url(&url) {
|
|
return Err(format!("Refusing to download non-official URL: {url}"));
|
|
}
|
|
|
|
let destination = self.destination_for_url(&url)?;
|
|
if let Some(parent) = destination.parent() {
|
|
fs::create_dir_all(parent).map_err(|error| {
|
|
format!(
|
|
"Failed to create destination directory {}: {error}",
|
|
parent.display()
|
|
)
|
|
})?;
|
|
}
|
|
|
|
self.download_one(&url, &destination)?;
|
|
let bytes = fs::metadata(&destination)
|
|
.map_err(|error| {
|
|
format!(
|
|
"Downloaded file missing at {}: {error}",
|
|
destination.display()
|
|
)
|
|
})?
|
|
.len();
|
|
|
|
items.push(OfficialResourcePullItem {
|
|
url,
|
|
destination,
|
|
bytes,
|
|
});
|
|
}
|
|
|
|
Ok(OfficialResourcePullReport { items })
|
|
}
|
|
|
|
/// Fetches an official URL into memory.
|
|
///
|
|
/// This is intended for small discovery inputs such as `server-info`,
|
|
/// `TableCatalog.bytes`, `BundlePackingInfo.bytes`, and
|
|
/// `MediaCatalog.bytes`.
|
|
pub fn fetch_bytes(&self, url: &str) -> Result<Vec<u8>, String> {
|
|
if !is_official_yostar_jp_url(url) {
|
|
return Err(format!("Refusing to fetch non-official URL: {url}"));
|
|
}
|
|
|
|
let output = Command::new(&self.curl_command)
|
|
.arg("--fail")
|
|
.arg("--location")
|
|
.arg("--silent")
|
|
.arg("--show-error")
|
|
.arg("--url")
|
|
.arg(url)
|
|
.output()
|
|
.map_err(|error| {
|
|
format!(
|
|
"Failed to launch curl command {}: {error}",
|
|
self.curl_command.display()
|
|
)
|
|
})?;
|
|
|
|
if !output.status.success() {
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
return Err(format!("curl failed for {url}: {}", stderr.trim()));
|
|
}
|
|
|
|
Ok(output.stdout)
|
|
}
|
|
|
|
fn download_one(&self, url: &str, destination: &Path) -> Result<(), String> {
|
|
let output = Command::new(&self.curl_command)
|
|
.arg("--fail")
|
|
.arg("--location")
|
|
.arg("--silent")
|
|
.arg("--show-error")
|
|
.arg("--output")
|
|
.arg(destination)
|
|
.arg("--url")
|
|
.arg(url)
|
|
.output()
|
|
.map_err(|error| {
|
|
format!(
|
|
"Failed to launch curl command {}: {error}",
|
|
self.curl_command.display()
|
|
)
|
|
})?;
|
|
|
|
if !output.status.success() {
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
return Err(format!(
|
|
"curl failed for {url} -> {}: {}",
|
|
destination.display(),
|
|
stderr.trim()
|
|
));
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn destination_for_url(&self, url: &str) -> Result<PathBuf, String> {
|
|
if !is_official_yostar_jp_url(url) {
|
|
return Err(format!("URL is not an official JP host: {url}"));
|
|
}
|
|
|
|
let rest = url
|
|
.strip_prefix("https://")
|
|
.ok_or_else(|| format!("Official URL must use https: {url}"))?;
|
|
let (host, path) = rest
|
|
.split_once('/')
|
|
.ok_or_else(|| format!("Official URL has no path: {url}"))?;
|
|
|
|
let mut destination = PathBuf::from(sanitize_segment(host));
|
|
for segment in path.split('/') {
|
|
if segment.is_empty() {
|
|
continue;
|
|
}
|
|
if segment == "." || segment == ".." {
|
|
return Err(format!("Official URL has unsafe path segment: {url}"));
|
|
}
|
|
|
|
let segment = segment.split(['?', '#']).next().unwrap_or(segment);
|
|
if segment.is_empty() {
|
|
continue;
|
|
}
|
|
destination.push(sanitize_segment(segment));
|
|
}
|
|
|
|
Ok(self.output_root.join(destination))
|
|
}
|
|
}
|
|
|
|
fn sanitize_segment(segment: &str) -> String {
|
|
segment
|
|
.chars()
|
|
.map(|ch| {
|
|
if matches!(ch, ':' | '*' | '?' | '"' | '<' | '>' | '|') {
|
|
'_'
|
|
} else {
|
|
ch
|
|
}
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::official_pull::{
|
|
build_official_pull_plan, build_official_pull_plan_for_platforms, OfficialResourcePullPlan,
|
|
};
|
|
use bat_adapters::official::inventory::{
|
|
YostarJpDownloadInventory, YostarJpPlatformDownloadInventory,
|
|
};
|
|
use bat_adapters::official::yostar_jp::{
|
|
PatchPlatform, YostarJpResourceDiscoveryPlan, YostarJpResourceEndpoint,
|
|
YostarJpResourceEndpointKind,
|
|
};
|
|
use std::fs;
|
|
use tempfile::TempDir;
|
|
|
|
fn discovery_plan() -> YostarJpResourceDiscoveryPlan {
|
|
YostarJpResourceDiscoveryPlan {
|
|
connection_group_name: "Prod-Audit".to_string(),
|
|
app_version: "1.70.0".to_string(),
|
|
bundle_version: Some("s8tloc7lo3".to_string()),
|
|
addressables_root:
|
|
"https://prod-clientpatch.bluearchiveyostar.com/r93_token".to_string(),
|
|
endpoints: vec![
|
|
YostarJpResourceEndpoint {
|
|
kind: YostarJpResourceEndpointKind::TableCatalog,
|
|
platform: None,
|
|
url: "https://prod-clientpatch.bluearchiveyostar.com/r93_token/TableBundles/TableCatalog.bytes".to_string(),
|
|
},
|
|
YostarJpResourceEndpoint {
|
|
kind: YostarJpResourceEndpointKind::TableCatalogHash,
|
|
platform: None,
|
|
url: "https://prod-clientpatch.bluearchiveyostar.com/r93_token/TableBundles/TableCatalog.hash".to_string(),
|
|
},
|
|
],
|
|
}
|
|
}
|
|
|
|
fn inventory() -> YostarJpDownloadInventory {
|
|
YostarJpDownloadInventory::from_catalog_bytes(
|
|
b"FullPatch_000.zip",
|
|
b"ExcelDB.db",
|
|
b"JP_Airi.zip",
|
|
)
|
|
}
|
|
|
|
fn platform_inventory() -> YostarJpPlatformDownloadInventory {
|
|
YostarJpPlatformDownloadInventory::from_shared_inventory(
|
|
inventory(),
|
|
&[PatchPlatform::Windows],
|
|
)
|
|
}
|
|
|
|
fn fake_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' "$url" > "$out"
|
|
else
|
|
printf '%s' "$url"
|
|
fi
|
|
"#
|
|
}
|
|
|
|
fn write_fake_curl(path: &Path) {
|
|
fs::write(path, fake_curl_script()).unwrap();
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt;
|
|
let mut permissions = fs::metadata(path).unwrap().permissions();
|
|
permissions.set_mode(0o755);
|
|
fs::set_permissions(path, permissions).unwrap();
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn downloads_verified_pull_plan_to_disk() {
|
|
let out_dir = TempDir::new().unwrap();
|
|
let bin_dir = TempDir::new().unwrap();
|
|
let curl_path = bin_dir.path().join("curl");
|
|
write_fake_curl(&curl_path);
|
|
|
|
let service = OfficialResourcePullService::with_curl_command(out_dir.path(), &curl_path);
|
|
let plan = build_official_pull_plan(discovery_plan(), inventory());
|
|
let report = service.pull(&plan).unwrap();
|
|
|
|
assert_eq!(report.items.len(), 7);
|
|
assert!(report.total_bytes() > 0);
|
|
assert_eq!(report.items[0].url, plan.discovery.endpoints[0].url);
|
|
for item in &report.items {
|
|
assert!(item.destination.exists());
|
|
assert_eq!(fs::read_to_string(&item.destination).unwrap(), item.url);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn custom_platform_selection_is_supported() {
|
|
let out_dir = TempDir::new().unwrap();
|
|
let bin_dir = TempDir::new().unwrap();
|
|
let curl_path = bin_dir.path().join("curl");
|
|
write_fake_curl(&curl_path);
|
|
|
|
let service = OfficialResourcePullService::with_curl_command(out_dir.path(), &curl_path);
|
|
let plan = build_official_pull_plan_for_platforms(
|
|
discovery_plan(),
|
|
inventory(),
|
|
&[PatchPlatform::Windows, PatchPlatform::Android],
|
|
);
|
|
let report = service.pull(&plan).unwrap();
|
|
|
|
assert_eq!(report.items.len(), 7);
|
|
assert!(report
|
|
.items
|
|
.iter()
|
|
.any(|item| item.url.contains("Android_PatchPack")));
|
|
}
|
|
|
|
#[test]
|
|
fn fetches_official_seed_bytes_with_curl() {
|
|
let out_dir = TempDir::new().unwrap();
|
|
let bin_dir = TempDir::new().unwrap();
|
|
let curl_path = bin_dir.path().join("curl");
|
|
write_fake_curl(&curl_path);
|
|
|
|
let service = OfficialResourcePullService::with_curl_command(out_dir.path(), &curl_path);
|
|
let bytes = service
|
|
.fetch_bytes(
|
|
"https://prod-clientpatch.bluearchiveyostar.com/r93_token/TableBundles/TableCatalog.bytes",
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
String::from_utf8(bytes).unwrap(),
|
|
"https://prod-clientpatch.bluearchiveyostar.com/r93_token/TableBundles/TableCatalog.bytes"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_non_official_urls() {
|
|
let service = OfficialResourcePullService::new("/tmp/unused");
|
|
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::TableCatalog,
|
|
platform: None,
|
|
url: "https://prod-clientpatch.bluearchive.cafe/r93_token/TableBundles/TableCatalog.bytes".to_string(),
|
|
}],
|
|
},
|
|
inventory: platform_inventory(),
|
|
platforms: vec![PatchPlatform::Windows],
|
|
};
|
|
|
|
assert!(service.pull(&plan).is_err());
|
|
}
|
|
}
|