//! 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, } 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) -> 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, curl_command: impl Into, ) -> 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 { 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, 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 { 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()); } }