fix(release): 收紧分发热路径与事务边界
bat-rust / Build and test Rust (push) Canceled after 0s
bat-rust / Build and test Go API (push) Canceled after 0s

This commit is contained in:
2026-09-12 22:22:11 +08:00
parent f2c20367a6
commit 786b739f99
17 changed files with 835 additions and 124 deletions
+2 -2
View File
@@ -33,14 +33,14 @@ impl FileSystemCasRepository {
/// Releases one release-owned CAS reference exactly once.
pub async fn release_reference_once(
&self,
release_id: &str,
ownership_id: &str,
ordinal: u64,
id: &ObjectId,
) -> bat_core::Result<bool> {
let hash = Self::parse_object_id(id)?;
self.engine()
.await?
.release_reference_once(release_id, ordinal, &hash)
.release_reference_once(ownership_id, ordinal, &hash)
.await
.map_err(Self::map_error)
}
+185 -8
View File
@@ -100,6 +100,8 @@ struct LocalizedReleaseTransaction {
current_target: Option<PathBuf>,
previous_state_bytes: Option<Vec<u8>>,
new_state: Option<LocalizedVersionState>,
#[serde(default)]
rollback_backup_path: Option<PathBuf>,
}
impl LocalizedReleaseTransaction {
@@ -121,6 +123,7 @@ impl LocalizedReleaseTransaction {
current_target: Some(Path::new(LOCALIZED_VERSIONS_DIR).join(release_id)),
previous_state_bytes,
new_state: None,
rollback_backup_path: None,
}
}
}
@@ -941,9 +944,33 @@ impl LocalizedPatchService {
current_target: manifest.rollback.previous_current_target.clone(),
previous_state_bytes: Some(previous_state_bytes),
new_state: Some(new_state.clone()),
rollback_backup_path: Some(localized_output_root.join(".rollback").join(format!(
"{}.{}",
current_release_id,
std::process::id()
))),
};
write_localized_transaction(localized_output_root, &transaction)?;
let mutation_result = (|| -> anyhow::Result<()> {
let backup_path = transaction
.rollback_backup_path
.as_deref()
.ok_or_else(|| anyhow::anyhow!("localized rollback 缺少备份路径"))?;
ensure_path_within_root(localized_output_root, backup_path)
.map_err(anyhow::Error::msg)?;
ensure_safe_directory_path(
backup_path.parent().unwrap_or(localized_output_root),
"localized rollback 备份目录",
)
.map_err(anyhow::Error::msg)?;
fs::create_dir_all(backup_path.parent().unwrap_or(localized_output_root))?;
ensure_safe_directory_path(
backup_path.parent().unwrap_or(localized_output_root),
"localized rollback 备份目录",
)
.map_err(anyhow::Error::msg)?;
fs::rename(&remove_version_path, backup_path)?;
update_localized_transaction_phase(localized_output_root, "version_staged")?;
restore_current_symlink(
localized_output_root,
&current_path,
@@ -952,8 +979,18 @@ impl LocalizedPatchService {
update_localized_transaction_phase(localized_output_root, "current_switched")?;
write_localized_version_state_unlocked(localized_output_root, &new_state)?;
update_localized_transaction_phase(localized_output_root, "state_written")?;
remove_owned_path(&remove_version_path)?;
if let Some(previous_target) = manifest.rollback.previous_current_target.as_deref() {
if !current_points_to_version(
&current_path,
&localized_output_root.join(previous_target),
)? {
return Err(anyhow::anyhow!("localized rollback current 最终校验失败"));
}
} else if fs::symlink_metadata(&current_path).is_ok() {
return Err(anyhow::anyhow!("localized rollback 应移除 current 指针"));
}
update_localized_transaction_phase(localized_output_root, "version_removed")?;
remove_owned_path(backup_path)?;
Ok(())
})();
if let Err(error) = mutation_result {
@@ -1216,6 +1253,7 @@ impl LocalizedPatchService {
translation_workflow_status,
updated_unix_seconds: unix_seconds_now(),
};
update_localized_transaction_state(&config.localized_output_root, &state)?;
write_file_atomic(
&state_path,
&serde_json::to_vec_pretty(&state)?,
@@ -1230,6 +1268,7 @@ impl LocalizedPatchService {
&current_path,
&config.unzip_command,
)?;
update_localized_transaction_phase(&config.localized_output_root, "verified")?;
Ok(LocalizedPatchReport {
version_path,
@@ -2139,6 +2178,7 @@ fn verify_published_localized_release(
&manifest,
unzip_command,
)?;
verify_localized_distribution_manifest_at(version_path, &manifest.localized_release_id)?;
integrity.current_points_to_release = current_points_to_version(current_path, version_path)?;
if !integrity.current_points_to_release {
return Err(anyhow::anyhow!(
@@ -2147,6 +2187,23 @@ fn verify_published_localized_release(
version_path.display()
));
}
let state_path = current_path
.parent()
.ok_or_else(|| anyhow::anyhow!("localized current 缺少输出根目录"))?
.join(LOCALIZED_VERSION_STATE_FILE);
let state = read_localized_version_state(
current_path
.parent()
.ok_or_else(|| anyhow::anyhow!("localized current 缺少输出根目录"))?,
)?
.ok_or_else(|| anyhow::anyhow!("缺少 localized version state{}", state_path.display()))?;
if state.current_release_id.as_deref() != Some(manifest.localized_release_id.as_str()) {
return Err(anyhow::anyhow!(
"localized version state 与发布 manifest 不一致:expected={} actual={:?}",
manifest.localized_release_id,
state.current_release_id
));
}
Ok(integrity)
}
@@ -2289,6 +2346,24 @@ fn update_localized_transaction_phase(
write_localized_transaction(localized_output_root, &transaction)
}
fn update_localized_transaction_state(
localized_output_root: &Path,
state: &LocalizedVersionState,
) -> anyhow::Result<()> {
let path = localized_output_root.join(LOCALIZED_TRANSACTION_FILE);
let Some(bytes) =
read_file_no_symlink(&path, "localized release transaction").map_err(anyhow::Error::msg)?
else {
return Err(anyhow::anyhow!(
"localized release transaction 丢失:{}",
path.display()
));
};
let mut transaction: LocalizedReleaseTransaction = serde_json::from_slice(&bytes)?;
transaction.new_state = Some(state.clone());
write_localized_transaction(localized_output_root, &transaction)
}
fn remove_localized_transaction(localized_output_root: &Path) -> anyhow::Result<()> {
let path = localized_output_root.join(LOCALIZED_TRANSACTION_FILE);
match fs::symlink_metadata(&path) {
@@ -2341,15 +2416,14 @@ fn recover_localized_transaction(localized_output_root: &Path) -> anyhow::Result
== Some(expected)
});
let publish_committed = transaction.operation == "publish"
&& transaction.phase == "verified"
&& transaction.version_path.is_dir()
&& current_matches
&& read_localized_version_state(localized_output_root)
.ok()
.flatten()
.and_then(|state| state.current_release_id)
.is_some_and(|id| id == transaction.release_id);
let rollback_committed =
transaction.operation == "rollback" && current_matches && state_matches;
&& state_matches;
let rollback_committed = transaction.operation == "rollback"
&& transaction.phase == "version_removed"
&& current_matches
&& state_matches;
if publish_committed {
if let Some(staging) = transaction.staging_path.as_deref() {
@@ -2357,6 +2431,9 @@ fn recover_localized_transaction(localized_output_root: &Path) -> anyhow::Result
}
} else if rollback_committed {
remove_owned_path(&transaction.version_path)?;
if let Some(backup) = transaction.rollback_backup_path.as_deref() {
remove_owned_path(backup)?;
}
} else {
if let Some(target) = transaction.previous_current_target.as_deref() {
let target_path = localized_output_root.join(target);
@@ -2372,6 +2449,14 @@ fn recover_localized_transaction(localized_output_root: &Path) -> anyhow::Result
}
remove_owned_path(&transaction.version_path)?;
}
if transaction.operation == "rollback" {
if let Some(backup) = transaction.rollback_backup_path.as_deref() {
if fs::symlink_metadata(backup).is_ok() {
remove_owned_path(&transaction.version_path)?;
fs::rename(backup, &transaction.version_path)?;
}
}
}
if let Some(previous_state) = transaction.previous_state_bytes.as_deref() {
write_file_atomic(
&localized_output_root.join(LOCALIZED_VERSION_STATE_FILE),
@@ -3533,6 +3618,98 @@ mod tests {
assert!(!localized_output_transaction_pending(&root).unwrap());
}
#[cfg(unix)]
#[test]
fn publish_recovery_requires_verified_phase_before_roll_forward() {
use std::os::unix::fs::symlink;
for (phase, new_is_current, should_keep_new) in [
("prepared", false, false),
("version_published", false, false),
("current_switched", true, false),
("state_written", true, false),
("verification_failed", true, false),
("verification_succeeded", true, false),
("verified", true, true),
] {
let temp = TempDir::new().unwrap();
let root = temp.path().join("localized");
let old = root.join(LOCALIZED_VERSIONS_DIR).join("old");
let new = root.join(LOCALIZED_VERSIONS_DIR).join("new");
let staging = root.join(LOCALIZED_STAGING_DIR).join("new");
fs::create_dir_all(&old).unwrap();
fs::create_dir_all(&new).unwrap();
fs::create_dir_all(&staging).unwrap();
symlink(
Path::new(LOCALIZED_VERSIONS_DIR).join(if new_is_current { "new" } else { "old" }),
root.join(LOCALIZED_CURRENT_LINK),
)
.unwrap();
let old_state = LocalizedVersionState {
state_version: LOCALIZED_VERSION_STATE_VERSION,
official_release_id: "official-old".to_string(),
current_release_id: Some("old".to_string()),
status: "localized".to_string(),
translation_workflow_status: None,
updated_unix_seconds: 1,
};
let new_state = LocalizedVersionState {
current_release_id: Some("new".to_string()),
updated_unix_seconds: 2,
..old_state.clone()
};
let old_state_bytes = serde_json::to_vec_pretty(&old_state).unwrap();
let state_bytes = if new_is_current {
serde_json::to_vec_pretty(&new_state).unwrap()
} else {
old_state_bytes.clone()
};
write_file_atomic(
&root.join(LOCALIZED_VERSION_STATE_FILE),
&state_bytes,
STATE_FILE_MODE,
"test state",
)
.unwrap();
let mut transaction = LocalizedReleaseTransaction::publish(
"new",
new.clone(),
staging.clone(),
Some(PathBuf::from("versions/old")),
Some(old_state_bytes),
);
transaction.phase = phase.to_string();
transaction.new_state = Some(new_state.clone());
write_localized_transaction(&root, &transaction).unwrap();
recover_localized_transaction(&root).unwrap();
if should_keep_new {
assert_eq!(
fs::read_link(root.join(LOCALIZED_CURRENT_LINK)).unwrap(),
PathBuf::from("versions/new")
);
assert_eq!(
read_localized_version_state(&root).unwrap(),
Some(new_state)
);
assert!(new.exists());
} else {
assert_eq!(
fs::read_link(root.join(LOCALIZED_CURRENT_LINK)).unwrap(),
PathBuf::from("versions/old")
);
assert_eq!(
read_localized_version_state(&root).unwrap(),
Some(old_state)
);
assert!(!new.exists());
}
assert!(!staging.exists());
assert!(!localized_output_transaction_pending(&root).unwrap());
}
}
fn push_i16_le(data: &mut Vec<u8>, value: i16) {
data.extend_from_slice(&value.to_le_bytes());
}
+115 -9
View File
@@ -25,6 +25,7 @@ use std::fs::{self, File};
use std::io::Read;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{SystemTime, UNIX_EPOCH};
@@ -74,6 +75,23 @@ const OFFICIAL_CAS_REUSE_REFERENCES_VERSION: u32 = 1;
const DOWNLOAD_MANIFEST_VERSION: u32 = 1;
const DOWNLOAD_QUARANTINE_VERSION: u32 = 1;
const DEFAULT_RETRY_ATTEMPTS: usize = 3;
static CAS_OWNERSHIP_SEQUENCE: AtomicU64 = AtomicU64::new(1);
fn new_cas_ownership_id(output_root: &Path) -> String {
let sequence = CAS_OWNERSHIP_SEQUENCE.fetch_add(1, Ordering::Relaxed);
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_nanos())
.unwrap_or_default();
let material = format!(
"{}:{}:{}:{}",
output_root.display(),
std::process::id(),
now,
sequence
);
format!("cas-owner-{}", blake3::hash(material.as_bytes()).to_hex())
}
/// Outcome for one official resource pull item.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
@@ -353,6 +371,12 @@ pub struct OfficialResourceReuseWarning {
pub struct OfficialCasReuseReferenceManifest {
/// 引用清单版本。
pub version: u32,
/// Persistent ownership identity for this release generation.
///
/// `None` is an explicit legacy manifest marker. Legacy cleanup keeps the
/// historical basename key and never invents a new identity while loading.
#[serde(default)]
pub ownership_id: Option<String>,
/// 每个 CAS 引用一项。允许重复,因为每个拉取项分别拥有一个引用。
pub object_ids: Vec<String>,
}
@@ -361,6 +385,7 @@ impl Default for OfficialCasReuseReferenceManifest {
fn default() -> Self {
Self {
version: OFFICIAL_CAS_REUSE_REFERENCES_VERSION,
ownership_id: None,
object_ids: Vec::new(),
}
}
@@ -393,15 +418,17 @@ pub fn read_cas_reuse_reference_manifest_at(
/// Decrements and removes CAS references recorded for a release.
///
/// Each decrement is committed together with a durable `(release, ordinal)`
/// ownership record in CAS metadata. The release-local manifest remains a
/// resumable progress cursor, so a crash before its rewrite cannot decrement
/// the same ownership twice.
/// Each decrement is committed together with a durable `(ownership, ordinal)`
/// record in CAS metadata. The release-local manifest remains a resumable
/// progress cursor, so a crash before its rewrite cannot decrement the same
/// ownership twice. Legacy manifests without `ownership_id` intentionally use
/// the historical release-basename key and are never assigned a new identity
/// during cleanup.
pub fn release_cas_reuse_references(release_root: &Path, cas_root: &Path) -> Result<usize, String> {
let Some(mut manifest) = read_cas_reuse_reference_manifest_at(release_root)? else {
return Ok(0);
};
let release_id = release_root
let legacy_release_id = release_root
.file_name()
.and_then(|name| name.to_str())
.filter(|name| !name.is_empty() && *name != "." && *name != "..")
@@ -412,6 +439,7 @@ pub fn release_cas_reuse_references(release_root: &Path, cas_root: &Path) -> Res
)
})?
.to_string();
let ownership_id = manifest.ownership_id.clone().unwrap_or(legacy_release_id);
let objects_root = cas_root.join("objects");
let metadata_path = cas_root.join("metadata.sqlite");
require_existing_directory(cas_root, "CAS 根目录")?;
@@ -422,14 +450,14 @@ pub fn release_cas_reuse_references(release_root: &Path, cas_root: &Path) -> Res
let ordinal = manifest.object_ids.len() as u64;
let cas_root = cas_root.to_path_buf();
let object_id_for_runtime = object_id.clone();
let release_id_for_runtime = release_id.clone();
let ownership_id_for_runtime = ownership_id.clone();
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|error| format!("创建 CAS 引用清理 runtime 失败:{error}"))?;
let did_release = runtime.block_on(async move {
let cas = crate::FileSystemCasRepository::new(cas_root);
cas.release_reference_once(&release_id_for_runtime, ordinal, &object_id_for_runtime)
cas.release_reference_once(&ownership_id_for_runtime, ordinal, &object_id_for_runtime)
.await
.map_err(|error| format!("减少 CAS release 引用失败 object={object_id}{error}"))
})?;
@@ -857,8 +885,14 @@ impl OfficialResourcePullService {
if object_ids.is_empty() {
return Ok(());
}
let mut manifest =
read_cas_reuse_reference_manifest_at(&self.output_root)?.unwrap_or_default();
let mut manifest = match read_cas_reuse_reference_manifest_at(&self.output_root)? {
Some(manifest) => manifest,
None => OfficialCasReuseReferenceManifest {
version: OFFICIAL_CAS_REUSE_REFERENCES_VERSION,
ownership_id: Some(new_cas_ownership_id(&self.output_root)),
object_ids: Vec::new(),
},
};
manifest.object_ids.extend(object_ids);
write_cas_reuse_reference_manifest(&self.output_root, &manifest)
}
@@ -4662,7 +4696,9 @@ exit 22
let references = read_cas_reuse_reference_manifest_at(&out_dir)
.unwrap()
.unwrap();
assert!(references.ownership_id.is_some());
assert_eq!(references.object_ids, vec![object_id.clone()]);
let ownership_id = references.ownership_id.clone().unwrap();
assert_eq!(cas_reference_count(&cas_root, &object_id), 2);
assert_eq!(
@@ -4680,6 +4716,7 @@ exit 22
out_dir.join(OFFICIAL_CAS_REUSE_REFERENCES_FILE),
serde_json::to_vec(&OfficialCasReuseReferenceManifest {
version: OFFICIAL_CAS_REUSE_REFERENCES_VERSION,
ownership_id: Some(ownership_id),
object_ids: vec![object_id.clone()],
})
.unwrap(),
@@ -4695,6 +4732,75 @@ exit 22
.is_none());
}
#[test]
fn cas_ownership_identity_isolated_for_same_release_basename() {
let temp = TempDir::new().unwrap();
let cas_root = temp.path().join("cas");
let object_id = store_cas_object(&cas_root, b"shared object");
let cas = crate::FileSystemCasRepository::new(&cas_root);
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap()
.block_on(async {
cas.add_reference(&object_id).await.unwrap();
cas.add_reference(&object_id).await.unwrap();
});
let first = temp.path().join("first").join("release");
let second = temp.path().join("second").join("release");
fs::create_dir_all(&first).unwrap();
fs::create_dir_all(&second).unwrap();
for (root, ownership_id) in [(&first, "owner-first"), (&second, "owner-second")] {
fs::write(
root.join(OFFICIAL_CAS_REUSE_REFERENCES_FILE),
serde_json::to_vec(&OfficialCasReuseReferenceManifest {
version: OFFICIAL_CAS_REUSE_REFERENCES_VERSION,
ownership_id: Some(ownership_id.to_string()),
object_ids: vec![object_id.clone()],
})
.unwrap(),
)
.unwrap();
}
assert_eq!(release_cas_reuse_references(&first, &cas_root).unwrap(), 1);
assert_eq!(cas_reference_count(&cas_root, &object_id), 2);
assert_eq!(release_cas_reuse_references(&second, &cas_root).unwrap(), 1);
assert_eq!(cas_reference_count(&cas_root, &object_id), 1);
}
#[test]
fn legacy_cas_manifest_uses_basename_key_without_migration() {
let temp = TempDir::new().unwrap();
let cas_root = temp.path().join("cas");
let object_id = store_cas_object(&cas_root, b"legacy object");
let release_root = temp.path().join("legacy-release");
fs::create_dir_all(&release_root).unwrap();
fs::write(
release_root.join(OFFICIAL_CAS_REUSE_REFERENCES_FILE),
serde_json::to_vec(&OfficialCasReuseReferenceManifest {
version: OFFICIAL_CAS_REUSE_REFERENCES_VERSION,
ownership_id: None,
object_ids: vec![object_id.clone()],
})
.unwrap(),
)
.unwrap();
let cas = crate::FileSystemCasRepository::new(&cas_root);
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap()
.block_on(async { cas.add_reference(&object_id).await.unwrap() });
assert_eq!(
release_cas_reuse_references(&release_root, &cas_root).unwrap(),
1
);
assert_eq!(cas_reference_count(&cas_root, &object_id), 1);
}
#[test]
fn corrupted_cas_falls_back_to_network_with_diagnostic() {
let temp = TempDir::new().unwrap();
+32 -11
View File
@@ -13,6 +13,7 @@ use crate::official_changes::{
write_official_resource_change_handoff, OfficialResourceChangeHandoffReport,
OfficialResourceChangeSummary,
};
use crate::official_download::OFFICIAL_CAS_REUSE_REFERENCES_FILE;
use crate::official_game_main_config::{
resolve_game_main_config_source, OfficialGameMainConfigSelectedSource,
OfficialGameMainConfigSourceKind,
@@ -1128,7 +1129,20 @@ impl OfficialPublishLayout {
if !path_exists_no_follow(active_root)? {
return Ok(());
}
copy_tree_no_symlink(active_root, staging_root, active_root == self.root)
copy_tree_no_symlink(active_root, staging_root, active_root == self.root)?;
if active_root != self.root {
let cas_references = staging_root.join(OFFICIAL_CAS_REUSE_REFERENCES_FILE);
if path_exists_no_follow(&cas_references)? {
ensure_safe_file_target(staging_root, &cas_references, "staging CAS 引用清单")?;
fs::remove_file(&cas_references).map_err(|error| {
format!(
"清理 active release CAS ownership 清单失败 {}{error}",
cas_references.display()
)
})?;
}
}
Ok(())
}
fn legacy_manifest_exists(&self) -> Result<bool, String> {
@@ -4309,21 +4323,22 @@ fn required_platform(endpoint: &YostarJpResourceEndpoint) -> anyhow::Result<Patc
}
#[derive(Debug)]
struct OfficialUpdateLock {
pub(crate) struct OfficialUpdateLock {
path: PathBuf,
}
impl OfficialUpdateLock {
fn acquire(config: &OfficialUpdateConfig) -> anyhow::Result<Self> {
validate_output_root(&config.output_root).map_err(anyhow::Error::msg)?;
ensure_safe_directory_path(&config.output_root, "资源输出目录")
.map_err(anyhow::Error::msg)?;
fs::create_dir_all(&config.output_root)?;
ensure_safe_directory_path(&config.output_root, "资源输出目录")
.map_err(anyhow::Error::msg)?;
let path = config.lock_path();
ensure_safe_file_target(&config.output_root, &path, "官方同步锁")
.map_err(anyhow::Error::msg)?;
Self::acquire_output_root(&config.output_root)
}
pub(crate) fn acquire_output_root(output_root: &Path) -> anyhow::Result<Self> {
validate_output_root(output_root).map_err(anyhow::Error::msg)?;
ensure_safe_directory_path(output_root, "资源输出目录").map_err(anyhow::Error::msg)?;
fs::create_dir_all(output_root)?;
ensure_safe_directory_path(output_root, "资源输出目录").map_err(anyhow::Error::msg)?;
let path = output_root.join(".official-sync.lock");
ensure_safe_file_target(output_root, &path, "官方同步锁").map_err(anyhow::Error::msg)?;
for attempt in 0..=1 {
let mut options = OpenOptions::new();
options.write(true).create_new(true);
@@ -4361,6 +4376,12 @@ impl OfficialUpdateLock {
}
}
pub(crate) fn acquire_official_output_lock(
output_root: &Path,
) -> anyhow::Result<OfficialUpdateLock> {
OfficialUpdateLock::acquire_output_root(output_root)
}
impl Drop for OfficialUpdateLock {
fn drop(&mut self) {
let expected = std::process::id().to_string();
+239 -55
View File
@@ -6,12 +6,13 @@
use crate::localized_patch::{
inspect_localized_release_artifact_at, read_localized_patch_manifest_at,
read_localized_version_state, LocalizedDistributionManifest, LOCALIZED_CURRENT_LINK,
LOCALIZED_DISTRIBUTION_MANIFEST_FILE, LOCALIZED_STAGING_DIR, LOCALIZED_VERSIONS_DIR,
read_localized_version_state, LocalizedDistributionEntry, LocalizedDistributionManifest,
LOCALIZED_CURRENT_LINK, LOCALIZED_DISTRIBUTION_MANIFEST_FILE, LOCALIZED_STAGING_DIR,
LOCALIZED_VERSIONS_DIR,
};
use crate::official_download::{
read_cas_reuse_reference_manifest_at, read_download_manifest_at, release_cas_reuse_references,
OfficialDownloadManifest,
OfficialDownloadManifest, OfficialDownloadManifestEntry,
};
use crate::official_update::{read_version_state, OfficialVersionRecord, OfficialVersionState};
use crate::path_security::{
@@ -363,15 +364,25 @@ pub fn select_release_distribution(
"请求的 release 不存在、publication identity 无效或不是当前 release",
));
};
let single_entry_lookup = params.destination.is_some();
let all_entries = selection.entries;
let total = all_entries.len();
let offset = params.offset.min(total);
let limit = if params.limit == 0 {
1000
let (total, offset, limit, entries) = if single_entry_lookup {
(1, 0, 1, all_entries)
} else {
params.limit.min(1000)
let total = all_entries.len();
let offset = params.offset.min(total);
let limit = if params.limit == 0 {
1000
} else {
params.limit.min(1000)
};
(
total,
offset,
limit,
all_entries.into_iter().skip(offset).take(limit).collect(),
)
};
let entries = all_entries.into_iter().skip(offset).take(limit).collect();
Ok(ReleaseDistributionPage {
available: true,
channel: channel.as_str().to_string(),
@@ -455,16 +466,35 @@ fn select_release_distribution_metadata(
let manifest = read_download_manifest_at(&path)
.map_err(anyhow::Error::msg)?
.ok_or_else(|| anyhow::anyhow!("official release 缺少官方下载 manifest"))?;
let entries = manifest
.entries
.values()
.map(|entry| ReleaseDistributionEntry {
let entries = if let Some(destination) = destination {
let mut matching_entries = manifest
.entries
.values()
.filter(|entry| entry.destination == destination);
let Some(entry) = matching_entries.next() else {
return Ok(None);
};
if matching_entries.next().is_some() {
return Ok(None);
}
vec![ReleaseDistributionEntry {
url: entry.url.clone(),
destination: entry.destination.clone(),
bytes: entry.bytes,
blake3: entry.blake3.clone(),
})
.collect::<Vec<_>>();
}]
} else {
manifest
.entries
.values()
.map(|entry| ReleaseDistributionEntry {
url: entry.url.clone(),
destination: entry.destination.clone(),
bytes: entry.bytes,
blake3: entry.blake3.clone(),
})
.collect::<Vec<_>>()
};
if !validate_distribution_entries(&path, &entries, destination)? {
return Ok(None);
}
@@ -502,19 +532,51 @@ fn select_release_distribution_metadata(
}) else {
return Ok(None);
};
if !localized_distribution_matches_official(&manifest, &official_manifest) {
return Ok(None);
}
let entries = manifest
.entries
.into_iter()
.map(|entry| ReleaseDistributionEntry {
url: entry.url,
destination: entry.destination,
let entries = if let Some(destination) = destination {
let mut matching_entries = manifest
.entries
.iter()
.filter(|entry| entry.destination == destination);
let Some(entry) = matching_entries.next() else {
return Ok(None);
};
if matching_entries.next().is_some() {
return Ok(None);
}
let mut official_entries = official_manifest
.entries
.values()
.filter(|official_entry| official_entry.destination == entry.destination);
let Some(official_entry) = official_entries.next() else {
return Ok(None);
};
if official_entries.next().is_some() {
return Ok(None);
}
if !localized_distribution_entry_matches_official(entry, official_entry) {
return Ok(None);
}
vec![ReleaseDistributionEntry {
url: entry.url.clone(),
destination: entry.destination.clone(),
bytes: entry.bytes,
blake3: entry.blake3,
})
.collect::<Vec<_>>();
blake3: entry.blake3.clone(),
}]
} else {
if !localized_distribution_matches_official(&manifest, &official_manifest) {
return Ok(None);
}
manifest
.entries
.into_iter()
.map(|entry| ReleaseDistributionEntry {
url: entry.url,
destination: entry.destination,
bytes: entry.bytes,
blake3: entry.blake3,
})
.collect::<Vec<_>>()
};
if !validate_distribution_entries(&path, &entries, destination)? {
return Ok(None);
}
@@ -534,38 +596,44 @@ fn validate_distribution_entries(
entries: &[ReleaseDistributionEntry],
destination: Option<&str>,
) -> anyhow::Result<bool> {
if let Some(destination) = destination {
let Some(entry) = entries
.iter()
.find(|entry| entry.destination == destination)
else {
return Ok(false);
};
let path = distribution_file_path(root, &entry.destination)?;
let bytes = fs::read(&path)?;
return Ok(bytes.len() as u64 == entry.bytes
&& blake3::hash(&bytes).to_hex().to_string() == entry.blake3);
}
for entry in entries {
let path = root.join(&entry.destination);
ensure_path_within_root(root, &path).map_err(anyhow::Error::msg)?;
if destination.is_none() {
let metadata = match fs::symlink_metadata(&path) {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
Err(error) => return Err(error.into()),
};
if !metadata.is_file()
|| metadata.file_type().is_symlink()
|| metadata.len() != entry.bytes
{
return Ok(false);
}
let path = distribution_file_path(root, &entry.destination)?;
let metadata = match fs::symlink_metadata(&path) {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
Err(error) => return Err(error.into()),
};
if !metadata.is_file() || metadata.file_type().is_symlink() || metadata.len() != entry.bytes
{
return Ok(false);
}
}
let Some(destination) = destination else {
return Ok(true);
};
let Some(entry) = entries
.iter()
.find(|entry| entry.destination == destination)
else {
return Ok(false);
};
Ok(true)
}
fn distribution_file_path(root: &Path, destination: &str) -> anyhow::Result<PathBuf> {
if destination.is_empty() || destination.contains('\\') || destination.contains('\0') {
return Err(anyhow::anyhow!(
"release distribution destination 不安全:{destination}"
));
}
let path = root.join(destination);
ensure_path_within_root(root, &path).map_err(anyhow::Error::msg)?;
ensure_safe_file_target(root, &path, "release distribution 文件")
.map_err(anyhow::Error::msg)?;
let bytes = fs::read(&path)?;
Ok(bytes.len() as u64 == entry.bytes
&& blake3::hash(&bytes).to_hex().to_string() == entry.blake3)
Ok(path)
}
fn localized_distribution_matches_official(
@@ -589,6 +657,13 @@ fn localized_distribution_matches_official(
})
}
fn localized_distribution_entry_matches_official(
localized: &LocalizedDistributionEntry,
official: &OfficialDownloadManifestEntry,
) -> bool {
localized.destination == official.destination && localized.url == official.url
}
fn blocked_distribution(
channel: Channel,
release_id: Option<String>,
@@ -624,8 +699,23 @@ pub fn cleanup_releases(
params: &ReleaseCleanupParams,
unzip_command: &Path,
) -> anyhow::Result<ReleaseCleanupReport> {
let _localized_lock = crate::localized_patch::acquire_localized_output_lock(localized_root)?;
crate::localized_patch::recover_localized_output_transaction(localized_root)?;
let _official_lock = if params.execute {
Some(crate::official_update::acquire_official_output_lock(
official_root,
)?)
} else {
None
};
let _localized_lock = if params.execute {
Some(crate::localized_patch::acquire_localized_output_lock(
localized_root,
)?)
} else {
None
};
if params.execute {
crate::localized_patch::recover_localized_output_transaction(localized_root)?;
}
let plan = build_cleanup_plan(official_root, localized_root, cas_root, unzip_command)?;
if !params.execute {
return Ok(ReleaseCleanupReport {
@@ -1566,6 +1656,76 @@ mod tests {
assert!(selected.resource_root.is_none());
}
#[test]
fn distribution_destination_uses_one_entry_without_hashing_other_entries() {
let temp = tempfile::tempdir().unwrap();
let official_root = temp.path().join("official");
let version = official_root.join(OFFICIAL_VERSIONS_DIR).join("large");
fs::create_dir_all(&version).unwrap();
let mut entries = BTreeMap::new();
let target = "resource-0000.bin";
for index in 0..5000 {
let destination = format!("resource-{index:04}.bin");
let data = format!("resource-{index}").into_bytes();
fs::write(version.join(&destination), &data).unwrap();
entries.insert(
format!("https://example.invalid/{destination}"),
OfficialDownloadManifestEntry {
url: format!("https://example.invalid/{destination}"),
destination,
bytes: data.len() as u64,
blake3: if index == 0 {
blake3::hash(&data).to_hex().to_string()
} else {
"0".repeat(64)
},
},
);
}
fs::write(
version.join("official-download-manifest.json"),
serde_json::to_vec(&OfficialDownloadManifest {
version: 1,
entries,
})
.unwrap(),
)
.unwrap();
fs::create_dir_all(&official_root).unwrap();
symlink(
Path::new(OFFICIAL_VERSIONS_DIR).join("large"),
official_root.join(OFFICIAL_CURRENT_LINK),
)
.unwrap();
fs::write(
official_root.join(OFFICIAL_VERSION_STATE_FILE),
serde_json::to_vec(&OfficialVersionState {
current_completed_version: Some(official_record(&official_root, "large")),
..OfficialVersionState::default()
})
.unwrap(),
)
.unwrap();
let selected = select_release_distribution(
&official_root,
&temp.path().join("localized"),
&ReleaseDistributionParams {
channel: Some("official".to_string()),
destination: Some(target.to_string()),
..ReleaseDistributionParams::default()
},
Path::new("unzip"),
)
.unwrap();
assert!(selected.available);
assert_eq!(selected.total, 1);
assert_eq!(selected.offset, 0);
assert_eq!(selected.limit, 1);
assert_eq!(selected.entries.len(), 1);
assert_eq!(selected.entries[0].destination, target);
}
#[test]
fn cleanup_plan_protects_current_and_removes_only_unreferenced_history() {
let temp = tempfile::tempdir().unwrap();
@@ -1676,6 +1836,30 @@ mod tests {
.exists());
}
#[test]
fn cleanup_execute_uses_the_official_sync_filesystem_lock() {
let temp = tempfile::tempdir().unwrap();
let official_root = temp.path().join("official");
let localized_root = temp.path().join("localized");
fs::create_dir_all(&official_root).unwrap();
let sync_lock =
crate::official_update::acquire_official_output_lock(&official_root).unwrap();
let result = cleanup_releases(
&official_root,
&localized_root,
&temp.path().join("cas"),
&ReleaseCleanupParams {
execute: true,
plan_id: Some("unused".to_string()),
},
Path::new("unzip"),
);
assert!(result.unwrap_err().to_string().contains("锁定"));
drop(sync_lock);
}
#[cfg(unix)]
#[test]
fn cleanup_retains_symlinks_and_localized_identity_injection() {