Files
BlueArchiveToolkit/infrastructure/src/official_changes.rs
T
nyaKazuha 2079c6a307 feat(sync): 接入解析缓存与汉化发布前置
补齐官方 release 解析缓存、TextUnit 明细索引、资源变更集、Crowdin handoff 预留、ResourceRepository 导入元数据和 localized release patch 前置链路。

同时开放文件级 patch.apply 与 UnityFS TextAsset/string/semantic field patch CLI/RPC 入口,并保留官方原版资源与汉化产物双目录发布状态。

验证:cargo test -p bat-assetbundle --locked;cargo clippy -p bat-assetbundle --all-targets --locked -- -D warnings;cargo test -p bat-infrastructure --locked。
2026-07-31 00:38:45 +08:00

654 lines
24 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Official resource change sets and translation handoff files.
//!
//! This module is intentionally file-based. Official update generates the
//! durable change set after a new release has been fully downloaded and
//! verified; parser and translation modules can then consume the same immutable
//! handoff without depending on daemon internals.
use crate::official_download::{
read_download_manifest_at, OfficialDownloadManifest, OfficialDownloadManifestEntry,
};
use crate::path_security::{
ensure_path_within_root, ensure_safe_file_target, read_file_no_symlink, write_file_atomic,
STATE_FILE_MODE,
};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
/// Current resource-change-set schema version.
pub const OFFICIAL_RESOURCE_CHANGES_VERSION: u32 = 1;
/// File name stored under a published official release root.
pub const OFFICIAL_RESOURCE_CHANGES_FILE: &str = "official-resource-changes.json";
/// Current Crowdin handoff schema version.
pub const CROWDIN_TRANSLATION_HANDOFF_VERSION: u32 = 1;
/// File name stored under a published official release root.
pub const CROWDIN_TRANSLATION_HANDOFF_FILE: &str = "crowdin-translation-handoff.json";
/// Change kind for one official resource destination.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum OfficialResourceChangeKind {
/// Destination did not exist in the previous complete release.
Added,
/// Destination exists in both releases, but verified size or BLAKE3 changed.
Modified,
/// Destination existed in the previous release but is absent from the new one.
Removed,
}
impl OfficialResourceChangeKind {
/// Returns the stable JSON/RPC label for the change kind.
pub fn as_str(self) -> &'static str {
match self {
Self::Added => "added",
Self::Modified => "modified",
Self::Removed => "removed",
}
}
/// Returns true when the changed resource should be offered to parser and
/// translation modules.
pub fn is_incremental_candidate(self) -> bool {
matches!(self, Self::Added | Self::Modified)
}
}
/// Verified manifest attributes for one official resource.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct OfficialResourceDescriptor {
/// Official URL from the download manifest.
pub url: String,
/// Relative path under the official release root.
pub destination: String,
/// Verified byte count from the download manifest.
pub bytes: u64,
/// Verified BLAKE3 digest from the download manifest.
pub blake3: String,
}
impl From<&OfficialDownloadManifestEntry> for OfficialResourceDescriptor {
fn from(entry: &OfficialDownloadManifestEntry) -> Self {
Self {
url: entry.url.clone(),
destination: entry.destination.clone(),
bytes: entry.bytes,
blake3: entry.blake3.clone(),
}
}
}
/// One resource-level change between two complete official releases.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct OfficialResourceChange {
/// Relative path used as the stable comparison key.
pub destination: String,
/// Change kind for this destination.
pub kind: OfficialResourceChangeKind,
/// Previous release descriptor, when the destination existed before.
pub previous: Option<OfficialResourceDescriptor>,
/// Current release descriptor, when the destination exists now.
pub current: Option<OfficialResourceDescriptor>,
/// Whether parser modules should inspect this resource in incremental mode.
pub parse_candidate: bool,
/// Whether translation modules should enqueue this resource in incremental mode.
pub translation_candidate: bool,
}
impl OfficialResourceChange {
fn new(
destination: String,
kind: OfficialResourceChangeKind,
previous: Option<OfficialResourceDescriptor>,
current: Option<OfficialResourceDescriptor>,
) -> Self {
let is_candidate = kind.is_incremental_candidate();
Self {
destination,
kind,
previous,
current,
parse_candidate: is_candidate,
translation_candidate: is_candidate,
}
}
}
/// Aggregate counters for one official resource change set.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct OfficialResourceChangeSummary {
/// Whether a previous complete release manifest was available.
pub previous_manifest_present: bool,
/// Number of entries in the previous release manifest.
pub previous_manifest_entry_count: usize,
/// Number of entries in the current release manifest.
pub current_manifest_entry_count: usize,
/// Number of newly added destinations.
pub added_count: usize,
/// Number of destinations whose verified bytes or BLAKE3 changed.
pub modified_count: usize,
/// Number of destinations removed from the current release.
pub removed_count: usize,
/// Number of resources to offer to parser modules.
pub parse_candidate_count: usize,
/// Number of resources to offer to translation modules.
pub translation_candidate_count: usize,
}
/// Durable comparison result between a previous complete official release and
/// the newly published official release.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct OfficialResourceChangeSet {
/// Change-set schema version.
#[serde(default = "default_resource_changes_version")]
pub change_set_version: u32,
/// Current official release ID.
pub official_release_id: String,
/// Previous official release ID, when known.
pub previous_release_id: Option<String>,
/// Generation time as Unix seconds.
pub generated_unix_seconds: u64,
/// Previous complete release root, when available.
pub previous_resource_root: Option<PathBuf>,
/// Current complete release root.
pub current_resource_root: PathBuf,
/// Aggregate counters.
pub summary: OfficialResourceChangeSummary,
/// Stable, destination-sorted list of changed resources.
pub changes: Vec<OfficialResourceChange>,
}
impl OfficialResourceChangeSet {
/// Builds a change set from already loaded manifests.
pub fn from_manifests(
official_release_id: impl Into<String>,
previous_release_id: Option<String>,
previous_resource_root: Option<PathBuf>,
current_resource_root: PathBuf,
previous_manifest: Option<&OfficialDownloadManifest>,
current_manifest: &OfficialDownloadManifest,
) -> Self {
let previous_by_destination = previous_manifest
.map(entries_by_destination)
.unwrap_or_default();
let current_by_destination = entries_by_destination(current_manifest);
let destinations = previous_by_destination
.keys()
.chain(current_by_destination.keys())
.cloned()
.collect::<BTreeSet<_>>();
let mut changes = Vec::new();
let mut summary = OfficialResourceChangeSummary {
previous_manifest_present: previous_manifest.is_some(),
previous_manifest_entry_count: previous_manifest
.map(|manifest| manifest.entries.len())
.unwrap_or(0),
current_manifest_entry_count: current_manifest.entries.len(),
..OfficialResourceChangeSummary::default()
};
for destination in destinations {
match (
previous_by_destination.get(&destination),
current_by_destination.get(&destination),
) {
(None, Some(current)) => {
summary.added_count += 1;
changes.push(OfficialResourceChange::new(
destination,
OfficialResourceChangeKind::Added,
None,
Some((*current).into()),
));
}
(Some(previous), Some(current)) if content_changed(previous, current) => {
summary.modified_count += 1;
changes.push(OfficialResourceChange::new(
destination,
OfficialResourceChangeKind::Modified,
Some((*previous).into()),
Some((*current).into()),
));
}
(Some(previous), None) => {
summary.removed_count += 1;
changes.push(OfficialResourceChange::new(
destination,
OfficialResourceChangeKind::Removed,
Some((*previous).into()),
None,
));
}
_ => {}
}
}
summary.parse_candidate_count = changes
.iter()
.filter(|change| change.parse_candidate)
.count();
summary.translation_candidate_count = changes
.iter()
.filter(|change| change.translation_candidate)
.count();
Self {
change_set_version: OFFICIAL_RESOURCE_CHANGES_VERSION,
official_release_id: official_release_id.into(),
previous_release_id,
generated_unix_seconds: unix_seconds_now(),
previous_resource_root,
current_resource_root,
summary,
changes,
}
}
/// Returns the resources that parser modules should inspect for
/// incremental work.
pub fn parse_candidates(&self) -> Vec<&OfficialResourceChange> {
self.changes
.iter()
.filter(|change| change.parse_candidate)
.collect()
}
/// Returns the resources that translation modules should enqueue.
pub fn translation_candidates(&self) -> Vec<&OfficialResourceChange> {
self.changes
.iter()
.filter(|change| change.translation_candidate)
.collect()
}
}
/// Provider reserved for translation handoff consumers.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TranslationHandoffProvider {
/// Crowdin provider. The handoff file does not make a network request.
Crowdin,
}
impl TranslationHandoffProvider {
/// Returns the stable provider label.
pub fn as_str(self) -> &'static str {
match self {
Self::Crowdin => "crowdin",
}
}
}
/// Status of a generated translation handoff.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TranslationHandoffStatus {
/// The handoff was written locally and is waiting for a translation worker.
QueuedOffline,
}
impl TranslationHandoffStatus {
/// Returns the stable status label.
pub fn as_str(self) -> &'static str {
match self {
Self::QueuedOffline => "queued_offline",
}
}
}
/// One resource entry queued for translation-provider processing.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TranslationHandoffResource {
/// Relative path under the official release root.
pub destination: String,
/// Change kind that caused this resource to be queued.
pub kind: OfficialResourceChangeKind,
/// Current official URL.
pub url: String,
/// Verified byte count.
pub bytes: u64,
/// Verified BLAKE3 digest.
pub blake3: String,
}
/// Crowdin-ready local queue file for added or modified official resources.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CrowdinTranslationHandoff {
/// Handoff schema version.
#[serde(default = "default_crowdin_handoff_version")]
pub handoff_version: u32,
/// Translation provider reserved for this queue.
pub provider: TranslationHandoffProvider,
/// Current queue status.
pub status: TranslationHandoffStatus,
/// Current official release ID.
pub official_release_id: String,
/// Previous official release ID, when known.
pub previous_release_id: Option<String>,
/// Generation time as Unix seconds.
pub generated_unix_seconds: u64,
/// Number of queued resources.
pub resource_count: usize,
/// Added or modified resources to pass into parsing/translation workers.
pub resources: Vec<TranslationHandoffResource>,
}
impl CrowdinTranslationHandoff {
/// Builds a local Crowdin handoff from a resource change set.
pub fn from_change_set(change_set: &OfficialResourceChangeSet) -> Self {
let resources = change_set
.translation_candidates()
.into_iter()
.filter_map(|change| {
let current = change.current.as_ref()?;
Some(TranslationHandoffResource {
destination: change.destination.clone(),
kind: change.kind,
url: current.url.clone(),
bytes: current.bytes,
blake3: current.blake3.clone(),
})
})
.collect::<Vec<_>>();
Self {
handoff_version: CROWDIN_TRANSLATION_HANDOFF_VERSION,
provider: TranslationHandoffProvider::Crowdin,
status: TranslationHandoffStatus::QueuedOffline,
official_release_id: change_set.official_release_id.clone(),
previous_release_id: change_set.previous_release_id.clone(),
generated_unix_seconds: unix_seconds_now(),
resource_count: resources.len(),
resources,
}
}
}
/// Generates a change set for two complete release roots and writes both the
/// change set and the Crowdin handoff under the current release root.
pub fn write_official_resource_change_handoff(
previous_resource_root: Option<&Path>,
current_resource_root: &Path,
official_release_id: &str,
previous_release_id: Option<String>,
) -> Result<OfficialResourceChangeHandoffReport, String> {
let current_manifest = read_download_manifest_at(current_resource_root)?.ok_or_else(|| {
format!(
"缺少当前官方下载 manifest,无法生成资源变更集:{}",
current_resource_root.display()
)
})?;
let previous_manifest = match previous_resource_root {
Some(root) => read_download_manifest_at(root)?,
None => None,
};
let previous_manifest_root = previous_manifest
.as_ref()
.and(previous_resource_root)
.map(Path::to_path_buf);
let previous_release_id = previous_manifest.as_ref().and(previous_release_id);
let change_set = OfficialResourceChangeSet::from_manifests(
official_release_id,
previous_release_id,
previous_manifest_root,
current_resource_root.to_path_buf(),
previous_manifest.as_ref(),
&current_manifest,
);
write_resource_change_set_at(current_resource_root, &change_set)?;
let handoff = CrowdinTranslationHandoff::from_change_set(&change_set);
write_crowdin_translation_handoff_at(current_resource_root, &handoff)?;
Ok(OfficialResourceChangeHandoffReport {
change_set_path: current_resource_root.join(OFFICIAL_RESOURCE_CHANGES_FILE),
crowdin_handoff_path: current_resource_root.join(CROWDIN_TRANSLATION_HANDOFF_FILE),
summary: change_set.summary,
})
}
/// Paths and summary produced after writing a resource-change handoff.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct OfficialResourceChangeHandoffReport {
/// Path to `official-resource-changes.json`.
pub change_set_path: PathBuf,
/// Path to `crowdin-translation-handoff.json`.
pub crowdin_handoff_path: PathBuf,
/// Aggregate change counters.
pub summary: OfficialResourceChangeSummary,
}
/// Reads a generated resource change set from a release root.
pub fn read_resource_change_set_at(
resource_root: &Path,
) -> Result<Option<OfficialResourceChangeSet>, String> {
let path = resource_root.join(OFFICIAL_RESOURCE_CHANGES_FILE);
let Some(bytes) = read_file_no_symlink(&path, "官方资源变更集")? else {
return Ok(None);
};
let change_set: OfficialResourceChangeSet = serde_json::from_slice(&bytes)
.map_err(|error| format!("解析官方资源变更集失败 {}{error}", path.display()))?;
if change_set.change_set_version != OFFICIAL_RESOURCE_CHANGES_VERSION {
return Err(format!(
"不支持的官方资源变更集版本 {},文件 {}",
change_set.change_set_version,
path.display()
));
}
Ok(Some(change_set))
}
/// Writes a generated resource change set under a release root.
pub fn write_resource_change_set_at(
resource_root: &Path,
change_set: &OfficialResourceChangeSet,
) -> Result<(), String> {
let path = resource_root.join(OFFICIAL_RESOURCE_CHANGES_FILE);
ensure_path_within_root(resource_root, &path)?;
ensure_safe_file_target(resource_root, &path, "官方资源变更集")?;
let bytes = serde_json::to_vec_pretty(change_set)
.map_err(|error| format!("序列化官方资源变更集失败:{error}"))?;
write_file_atomic(&path, &bytes, STATE_FILE_MODE, "官方资源变更集")
}
/// Writes a generated Crowdin handoff under a release root.
pub fn write_crowdin_translation_handoff_at(
resource_root: &Path,
handoff: &CrowdinTranslationHandoff,
) -> Result<(), String> {
let path = resource_root.join(CROWDIN_TRANSLATION_HANDOFF_FILE);
ensure_path_within_root(resource_root, &path)?;
ensure_safe_file_target(resource_root, &path, "Crowdin 翻译 handoff")?;
let bytes = serde_json::to_vec_pretty(handoff)
.map_err(|error| format!("序列化 Crowdin 翻译 handoff 失败:{error}"))?;
write_file_atomic(&path, &bytes, STATE_FILE_MODE, "Crowdin 翻译 handoff")
}
fn entries_by_destination(
manifest: &OfficialDownloadManifest,
) -> BTreeMap<String, &OfficialDownloadManifestEntry> {
manifest
.entries
.values()
.map(|entry| (entry.destination.clone(), entry))
.collect()
}
fn content_changed(
previous: &OfficialDownloadManifestEntry,
current: &OfficialDownloadManifestEntry,
) -> bool {
previous.bytes != current.bytes || previous.blake3 != current.blake3
}
fn unix_seconds_now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
fn default_resource_changes_version() -> u32 {
OFFICIAL_RESOURCE_CHANGES_VERSION
}
fn default_crowdin_handoff_version() -> u32 {
CROWDIN_TRANSLATION_HANDOFF_VERSION
}
#[cfg(test)]
mod tests {
use super::*;
fn manifest(entries: &[(&str, &str, &[u8])]) -> OfficialDownloadManifest {
let mut manifest = OfficialDownloadManifest::default();
for (url, destination, bytes) in entries {
manifest.entries.insert(
(*url).to_string(),
OfficialDownloadManifestEntry {
url: (*url).to_string(),
destination: (*destination).to_string(),
bytes: bytes.len() as u64,
blake3: blake3::hash(bytes).to_hex().to_string(),
},
);
}
manifest
}
fn write_manifest(root: &Path, manifest: &OfficialDownloadManifest) {
std::fs::create_dir_all(root).unwrap();
std::fs::write(
root.join("official-download-manifest.json"),
serde_json::to_vec(manifest).unwrap(),
)
.unwrap();
}
#[test]
fn change_set_classifies_added_modified_and_removed_resources() {
let previous = manifest(&[
("https://old/a", "TableBundles/a.bytes", b"old-a"),
("https://old/b", "TableBundles/b.bytes", b"same"),
("https://old/c", "TableBundles/c.bytes", b"removed"),
(
"https://old/u",
"TableBundles/url-only.bytes",
b"same-url-only",
),
]);
let current = manifest(&[
("https://new/a", "TableBundles/a.bytes", b"new-a"),
("https://new/b", "TableBundles/b.bytes", b"same"),
("https://new/d", "TableBundles/d.bytes", b"added"),
(
"https://changed-host/u",
"TableBundles/url-only.bytes",
b"same-url-only",
),
]);
let change_set = OfficialResourceChangeSet::from_manifests(
"release-new",
Some("release-old".to_string()),
Some(PathBuf::from("/previous")),
PathBuf::from("/current"),
Some(&previous),
&current,
);
assert_eq!(change_set.summary.added_count, 1);
assert_eq!(change_set.summary.modified_count, 1);
assert_eq!(change_set.summary.removed_count, 1);
assert_eq!(change_set.summary.parse_candidate_count, 2);
assert_eq!(change_set.summary.translation_candidate_count, 2);
let destinations = change_set
.translation_candidates()
.into_iter()
.map(|change| change.destination.as_str())
.collect::<Vec<_>>();
assert_eq!(
destinations,
vec!["TableBundles/a.bytes", "TableBundles/d.bytes"]
);
}
#[test]
fn first_release_treats_all_current_resources_as_added() {
let current = manifest(&[
("https://new/a", "a.bundle", b"a"),
("https://new/b", "b.bundle", b"b"),
]);
let change_set = OfficialResourceChangeSet::from_manifests(
"release-new",
None,
None,
PathBuf::from("/current"),
None,
&current,
);
assert!(!change_set.summary.previous_manifest_present);
assert_eq!(change_set.summary.added_count, 2);
assert_eq!(change_set.summary.translation_candidate_count, 2);
assert_eq!(change_set.changes.len(), 2);
}
#[test]
fn crowdin_handoff_excludes_removed_resources() {
let previous = manifest(&[("https://old/a", "a.bundle", b"a")]);
let current = manifest(&[("https://new/b", "b.bundle", b"b")]);
let change_set = OfficialResourceChangeSet::from_manifests(
"release-new",
Some("release-old".to_string()),
None,
PathBuf::from("/current"),
Some(&previous),
&current,
);
let handoff = CrowdinTranslationHandoff::from_change_set(&change_set);
assert_eq!(handoff.provider, TranslationHandoffProvider::Crowdin);
assert_eq!(handoff.status, TranslationHandoffStatus::QueuedOffline);
assert_eq!(handoff.resource_count, 1);
assert_eq!(handoff.resources[0].destination, "b.bundle");
}
#[test]
fn write_handoff_persists_change_set_and_crowdin_queue() {
let temp = tempfile::TempDir::new().unwrap();
let previous_root = temp.path().join("previous");
let current_root = temp.path().join("current");
write_manifest(
&previous_root,
&manifest(&[("https://old/a", "a.bundle", b"old")]),
);
write_manifest(
&current_root,
&manifest(&[
("https://new/a", "a.bundle", b"new"),
("https://new/b", "b.bundle", b"added"),
]),
);
let report = write_official_resource_change_handoff(
Some(&previous_root),
&current_root,
"release-new",
Some("release-old".to_string()),
)
.unwrap();
assert_eq!(report.summary.modified_count, 1);
assert_eq!(report.summary.added_count, 1);
assert!(report.change_set_path.exists());
assert!(report.crowdin_handoff_path.exists());
let change_set = read_resource_change_set_at(&current_root).unwrap().unwrap();
assert_eq!(change_set.summary.translation_candidate_count, 2);
}
}