mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-07-22 07:16:45 +08:00
feat: prepare experiment push package
This commit is contained in:
@@ -0,0 +1,396 @@
|
||||
//! 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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user