feat: prepare experiment push package

This commit is contained in:
2026-06-30 00:08:36 +08:00
parent 3c00659691
commit adc1cd5b91
45 changed files with 7858 additions and 65 deletions
+396
View File
@@ -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());
}
}