mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-09-18 06:34:54 +08:00
fix(release): 完成官方发布身份与 legacy CAS 归属保护
This commit is contained in:
@@ -89,6 +89,7 @@ pub use official_download::{
|
||||
OfficialResourcePullProgress, OfficialResourcePullProgressKind, OfficialResourcePullReport,
|
||||
OfficialResourcePullService, OfficialResourcePullStatus, OfficialResourceReuseWarning,
|
||||
OfficialResourceVerification, OFFICIAL_CAS_REUSE_REFERENCES_FILE,
|
||||
OFFICIAL_DISTRIBUTION_PUBLICATION_FILE,
|
||||
};
|
||||
pub use official_game_main_config::OfficialGameMainConfigBootstrapService;
|
||||
pub use official_launcher::{
|
||||
|
||||
@@ -2325,8 +2325,16 @@ fn verify_localized_distribution_manifest_at(
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!("localized distribution 缺少 source official manifest")
|
||||
})?;
|
||||
let source_mapping_identity =
|
||||
crate::official_download::official_distribution_mapping_identity(&official_manifest);
|
||||
let official_anchor =
|
||||
crate::official_download::verify_official_distribution_publication_at(
|
||||
official_release_root,
|
||||
&manifest.official_release_id,
|
||||
)
|
||||
.map_err(anyhow::Error::msg)?
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!("localized distribution 缺少 official publication anchor")
|
||||
})?;
|
||||
let source_mapping_identity = official_anchor.mapping_identity.clone();
|
||||
let expected_destination_index =
|
||||
crate::official_download::official_distribution_destination_index(&official_manifest)
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
@@ -4196,6 +4204,11 @@ mod tests {
|
||||
serde_json::to_vec(&official_manifest).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
crate::official_download::write_official_distribution_publication_anchor_at(
|
||||
&official,
|
||||
"official-v1",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let manifest = bat_patch::build_patch_manifest(
|
||||
&official,
|
||||
|
||||
@@ -69,6 +69,8 @@ impl std::error::Error for DownloadError {}
|
||||
|
||||
const DOWNLOAD_MANIFEST_FILE: &str = "official-download-manifest.json";
|
||||
const DOWNLOAD_QUARANTINE_FILE: &str = "official-download-quarantine.json";
|
||||
/// Independent publication fact for the official distribution manifest.
|
||||
pub const OFFICIAL_DISTRIBUTION_PUBLICATION_FILE: &str = "official-distribution-publication.json";
|
||||
/// 记录一个已发布官方 release 获取的 CAS 引用。
|
||||
pub const OFFICIAL_CAS_REUSE_REFERENCES_FILE: &str = "official-cas-reuse-references.json";
|
||||
const OFFICIAL_CAS_REUSE_REFERENCES_VERSION: u32 = 1;
|
||||
@@ -553,6 +555,158 @@ fn cas_has_ownership(cas_root: &Path, ownership_id: &str) -> Result<bool, String
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub(crate) struct OfficialDistributionPublicationAnchor {
|
||||
pub(crate) version: u32,
|
||||
pub(crate) official_release_id: String,
|
||||
pub(crate) mapping_identity: String,
|
||||
pub(crate) manifest_identity: String,
|
||||
pub(crate) entry_count: u64,
|
||||
}
|
||||
|
||||
const OFFICIAL_DISTRIBUTION_PUBLICATION_VERSION: u32 = 1;
|
||||
|
||||
/// Writes the independent publication anchor after the complete official
|
||||
/// release verification has succeeded.
|
||||
///
|
||||
/// The caller must invoke this only for a fully audited staging tree immediately
|
||||
/// before publishing it. The function repeats the manifest/file checks so the
|
||||
/// anchor cannot be created over an incomplete tree.
|
||||
pub(crate) fn write_official_distribution_publication_anchor_at(
|
||||
release_root: &Path,
|
||||
official_release_id: &str,
|
||||
) -> Result<(), String> {
|
||||
if official_release_id.is_empty() {
|
||||
return Err("官方 distribution publication 缺少 release ID".to_string());
|
||||
}
|
||||
ensure_safe_directory_path(release_root, "官方 distribution publication 根目录")?;
|
||||
let manifest_path = release_root.join(DOWNLOAD_MANIFEST_FILE);
|
||||
let manifest_bytes = read_file_no_symlink(&manifest_path, "官方下载 manifest")?
|
||||
.ok_or_else(|| format!("缺少官方下载 manifest:{}", manifest_path.display()))?;
|
||||
let manifest: OfficialDownloadManifest =
|
||||
serde_json::from_slice(&manifest_bytes).map_err(|error| {
|
||||
format!(
|
||||
"解析官方下载 manifest 失败 {}:{error}",
|
||||
manifest_path.display()
|
||||
)
|
||||
})?;
|
||||
if manifest.version != DOWNLOAD_MANIFEST_VERSION {
|
||||
return Err(format!(
|
||||
"不支持的官方下载 manifest 版本:{}",
|
||||
manifest.version
|
||||
));
|
||||
}
|
||||
let mapping_identity = official_distribution_mapping_identity(&manifest);
|
||||
let expected_index = official_distribution_destination_index(&manifest)?;
|
||||
if manifest.distribution_mapping_identity.as_deref() != Some(mapping_identity.as_str())
|
||||
|| manifest.destination_index != expected_index
|
||||
{
|
||||
return Err(format!(
|
||||
"官方下载 manifest 未完成 distribution mapping 验证:{}",
|
||||
manifest_path.display()
|
||||
));
|
||||
}
|
||||
for entry in manifest.entries.values() {
|
||||
let path = release_root.join(&entry.destination);
|
||||
ensure_path_within_root(release_root, &path)?;
|
||||
ensure_safe_file_target(release_root, &path, "官方 distribution 文件")?;
|
||||
let bytes = fs::read(&path).map_err(|error| {
|
||||
format!("读取官方 distribution 文件失败 {}:{error}", path.display())
|
||||
})?;
|
||||
let actual = blake3::hash(&bytes).to_hex().to_string();
|
||||
if bytes.len() as u64 != entry.bytes || actual != entry.blake3 {
|
||||
return Err(format!(
|
||||
"官方 distribution 文件完整性失败:{}",
|
||||
entry.destination
|
||||
));
|
||||
}
|
||||
if url_or_path_has_zip_extension(&entry.url) || path_has_zip_extension(&path) {
|
||||
validate_zip_structure(&path).map_err(|error| {
|
||||
format!(
|
||||
"官方 distribution ZIP 结构校验失败 {}:{error}",
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
}
|
||||
|
||||
let anchor = OfficialDistributionPublicationAnchor {
|
||||
version: OFFICIAL_DISTRIBUTION_PUBLICATION_VERSION,
|
||||
official_release_id: official_release_id.to_string(),
|
||||
mapping_identity,
|
||||
manifest_identity: blake3::hash(&manifest_bytes).to_hex().to_string(),
|
||||
entry_count: manifest.entries.len() as u64,
|
||||
};
|
||||
let anchor_path = release_root.join(OFFICIAL_DISTRIBUTION_PUBLICATION_FILE);
|
||||
ensure_safe_file_target(release_root, &anchor_path, "官方 distribution publication")?;
|
||||
let anchor_bytes = serde_json::to_vec_pretty(&anchor)
|
||||
.map_err(|error| format!("序列化官方 distribution publication 失败:{error}"))?;
|
||||
write_file_atomic(
|
||||
&anchor_path,
|
||||
&anchor_bytes,
|
||||
STATE_FILE_MODE,
|
||||
"官方 distribution publication",
|
||||
)
|
||||
}
|
||||
|
||||
/// Verifies a published official distribution anchor without hashing resource
|
||||
/// files or canonicalizing the complete mapping.
|
||||
pub(crate) fn verify_official_distribution_publication_at(
|
||||
release_root: &Path,
|
||||
expected_release_id: &str,
|
||||
) -> Result<Option<OfficialDistributionPublicationAnchor>, String> {
|
||||
ensure_safe_directory_path(release_root, "官方 distribution publication 根目录")?;
|
||||
let anchor_path = release_root.join(OFFICIAL_DISTRIBUTION_PUBLICATION_FILE);
|
||||
let Some(anchor_bytes) = read_file_no_symlink(&anchor_path, "官方 distribution publication")?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let anchor: OfficialDistributionPublicationAnchor = serde_json::from_slice(&anchor_bytes)
|
||||
.map_err(|error| format!("解析官方 distribution publication 失败:{error}"))?;
|
||||
if anchor.version != OFFICIAL_DISTRIBUTION_PUBLICATION_VERSION
|
||||
|| anchor.official_release_id != expected_release_id
|
||||
|| anchor.mapping_identity.is_empty()
|
||||
|| anchor.manifest_identity.is_empty()
|
||||
{
|
||||
return Err(format!(
|
||||
"官方 distribution publication anchor 不一致:{}",
|
||||
anchor_path.display()
|
||||
));
|
||||
}
|
||||
let manifest_path = release_root.join(DOWNLOAD_MANIFEST_FILE);
|
||||
let Some(manifest_bytes) = read_file_no_symlink(&manifest_path, "官方下载 manifest")?
|
||||
else {
|
||||
return Err(format!(
|
||||
"官方 distribution publication 缺少 manifest:{}",
|
||||
manifest_path.display()
|
||||
));
|
||||
};
|
||||
let manifest: OfficialDownloadManifest =
|
||||
serde_json::from_slice(&manifest_bytes).map_err(|error| {
|
||||
format!(
|
||||
"解析官方下载 manifest 失败 {}:{error}",
|
||||
manifest_path.display()
|
||||
)
|
||||
})?;
|
||||
if manifest.version != DOWNLOAD_MANIFEST_VERSION {
|
||||
return Err(format!(
|
||||
"不支持的官方下载 manifest 版本:{}",
|
||||
manifest.version
|
||||
));
|
||||
}
|
||||
if manifest.distribution_mapping_identity.as_deref() != Some(anchor.mapping_identity.as_str())
|
||||
|| blake3::hash(&manifest_bytes).to_hex().to_string() != anchor.manifest_identity
|
||||
|| manifest.entries.len() as u64 != anchor.entry_count
|
||||
|| manifest.destination_index.len() as u64 != anchor.entry_count
|
||||
{
|
||||
return Err(format!(
|
||||
"官方 distribution publication 与 manifest 不一致:{}",
|
||||
release_root.display()
|
||||
));
|
||||
}
|
||||
Ok(Some(anchor))
|
||||
}
|
||||
|
||||
fn legacy_source_mapping_identity(release_root: &Path) -> Result<String, String> {
|
||||
let Some(manifest) = read_download_manifest_at(release_root)? else {
|
||||
return Ok("missing-official-manifest".to_string());
|
||||
@@ -602,11 +756,11 @@ fn resolve_legacy_ownership(
|
||||
return Ok((release_id, true));
|
||||
}
|
||||
if cas_has_ownership(cas_root, &release_id)? {
|
||||
scope
|
||||
.legacy_basename_compatibility
|
||||
.insert(release_id.clone());
|
||||
write_owner_scope_state(release_root, &scope)?;
|
||||
return Ok((release_id, true));
|
||||
return Err(format!(
|
||||
"ambiguous_legacy_ownership: CAS basename ledger {} 无法证明属于 output root {};请先显式记录 compatibility marker",
|
||||
release_id,
|
||||
ownership_scope_root(release_root).display()
|
||||
));
|
||||
}
|
||||
|
||||
let source_identity = legacy_source_mapping_identity(release_root)?;
|
||||
@@ -5088,6 +5242,11 @@ exit 22
|
||||
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();
|
||||
let mut scope = load_owner_scope_state(&release_root).unwrap();
|
||||
scope
|
||||
.legacy_basename_compatibility
|
||||
.insert("legacy-release".to_string());
|
||||
write_owner_scope_state(&release_root, &scope).unwrap();
|
||||
fs::write(
|
||||
release_root.join(OFFICIAL_CAS_REUSE_REFERENCES_FILE),
|
||||
serde_json::to_vec(&OfficialCasReuseReferenceManifest {
|
||||
@@ -5112,6 +5271,61 @@ exit 22
|
||||
assert_eq!(cas_reference_count(&cas_root, &object_id), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_basename_ledger_without_root_marker_is_ambiguous() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let cas_root = temp.path().join("cas");
|
||||
let output_a = temp.path().join("output-a/versions/release-x");
|
||||
let output_b = temp.path().join("output-b/versions/release-x");
|
||||
fs::create_dir_all(&output_a).unwrap();
|
||||
fs::create_dir_all(&output_b).unwrap();
|
||||
let object_id = store_cas_object(&cas_root, b"shared legacy 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();
|
||||
assert!(cas
|
||||
.release_reference_once("release-x", 0, &object_id)
|
||||
.await
|
||||
.unwrap());
|
||||
});
|
||||
let mut output_a_scope = load_owner_scope_state(&output_a).unwrap();
|
||||
output_a_scope
|
||||
.legacy_basename_compatibility
|
||||
.insert("release-x".to_string());
|
||||
write_owner_scope_state(&output_a, &output_a_scope).unwrap();
|
||||
let manifest_path = output_b.join(OFFICIAL_CAS_REUSE_REFERENCES_FILE);
|
||||
let manifest_bytes = serde_json::to_vec(&OfficialCasReuseReferenceManifest {
|
||||
version: OFFICIAL_CAS_REUSE_REFERENCES_VERSION,
|
||||
ownership_id: None,
|
||||
object_ids: vec![object_id.clone()],
|
||||
})
|
||||
.unwrap();
|
||||
fs::write(&manifest_path, &manifest_bytes).unwrap();
|
||||
|
||||
let result = release_cas_reuse_references(&output_b, &cas_root);
|
||||
|
||||
let error = result.unwrap_err();
|
||||
assert!(error.contains("ambiguous_legacy_ownership"));
|
||||
assert_eq!(cas_reference_count(&cas_root, &object_id), 2);
|
||||
let ledger_exists = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap()
|
||||
.block_on(async { cas.has_release_ownership("release-x").await.unwrap() });
|
||||
assert!(ledger_exists);
|
||||
assert_eq!(fs::read(&manifest_path).unwrap(), manifest_bytes);
|
||||
assert!(output_b.is_dir());
|
||||
let output_b_scope = load_owner_scope_state(&output_b).unwrap();
|
||||
assert!(!output_b_scope
|
||||
.legacy_basename_compatibility
|
||||
.contains("release-x"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_manifest_migration_persists_identity_before_retry() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
@@ -5161,6 +5375,11 @@ exit 22
|
||||
let cas_root = temp.path().join("cas");
|
||||
let first = temp.path().join("versions").join("release-x");
|
||||
fs::create_dir_all(&first).unwrap();
|
||||
let mut scope = load_owner_scope_state(&first).unwrap();
|
||||
scope
|
||||
.legacy_basename_compatibility
|
||||
.insert("release-x".to_string());
|
||||
write_owner_scope_state(&first, &scope).unwrap();
|
||||
let first_object = store_cas_object(&cas_root, b"legacy-first");
|
||||
let second_object = store_cas_object(&cas_root, b"legacy-second");
|
||||
let cas = crate::FileSystemCasRepository::new(&cas_root);
|
||||
|
||||
@@ -13,7 +13,10 @@ use crate::official_changes::{
|
||||
write_official_resource_change_handoff, OfficialResourceChangeHandoffReport,
|
||||
OfficialResourceChangeSummary,
|
||||
};
|
||||
use crate::official_download::OFFICIAL_CAS_REUSE_REFERENCES_FILE;
|
||||
use crate::official_download::{
|
||||
write_official_distribution_publication_anchor_at, OFFICIAL_CAS_REUSE_REFERENCES_FILE,
|
||||
OFFICIAL_DISTRIBUTION_PUBLICATION_FILE,
|
||||
};
|
||||
use crate::official_game_main_config::{
|
||||
resolve_game_main_config_source, OfficialGameMainConfigSelectedSource,
|
||||
OfficialGameMainConfigSourceKind,
|
||||
@@ -2003,6 +2006,15 @@ impl OfficialUpdateService {
|
||||
"audit",
|
||||
verification_progress_message(&final_verification_summary),
|
||||
));
|
||||
progress(OfficialUpdateProgress::new(
|
||||
"publish",
|
||||
"写入官方 distribution publication anchor",
|
||||
));
|
||||
write_official_distribution_publication_anchor_at(
|
||||
&publish_plan.staging_path,
|
||||
&publish_plan.id,
|
||||
)
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
progress(OfficialUpdateProgress::new(
|
||||
"snapshot",
|
||||
format!("写入快照 {}", staging_snapshot_path.display()),
|
||||
@@ -3210,6 +3222,7 @@ fn copy_tree_no_symlink(
|
||||
| OFFICIAL_CURRENT_LINK
|
||||
| ".official-sync.lock"
|
||||
| ".cas-owner-scope"
|
||||
| OFFICIAL_DISTRIBUTION_PUBLICATION_FILE
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -12,7 +12,8 @@ use crate::localized_patch::{
|
||||
};
|
||||
use crate::official_download::{
|
||||
read_cas_reuse_reference_manifest_at, read_download_manifest_at, release_cas_reuse_references,
|
||||
OfficialDownloadManifest, OfficialDownloadManifestEntry,
|
||||
verify_official_distribution_publication_at, OfficialDownloadManifest,
|
||||
OfficialDownloadManifestEntry,
|
||||
};
|
||||
use crate::official_update::{read_version_state, OfficialVersionRecord, OfficialVersionState};
|
||||
use crate::path_security::{
|
||||
@@ -466,11 +467,14 @@ 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"))?;
|
||||
if manifest.distribution_mapping_identity.is_none()
|
||||
|| manifest.destination_index.len() != manifest.entries.len()
|
||||
{
|
||||
let Some(_publication_anchor) =
|
||||
(match verify_official_distribution_publication_at(&path, id) {
|
||||
Ok(anchor) => anchor,
|
||||
Err(_) => return Ok(None),
|
||||
})
|
||||
else {
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
let entries = if let Some(destination) = destination {
|
||||
let Some(url) = manifest.destination_index.get(destination) else {
|
||||
return Ok(None);
|
||||
@@ -536,12 +540,16 @@ fn select_release_distribution_metadata(
|
||||
}) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(source_mapping_identity) =
|
||||
official_manifest.distribution_mapping_identity.as_deref()
|
||||
else {
|
||||
let Some(official_anchor) = (match verify_official_distribution_publication_at(
|
||||
&source_path,
|
||||
&manifest.official_release_id,
|
||||
) {
|
||||
Ok(anchor) => anchor,
|
||||
Err(_) => return Ok(None),
|
||||
}) else {
|
||||
return Ok(None);
|
||||
};
|
||||
if manifest.source_mapping_identity != source_mapping_identity
|
||||
if manifest.source_mapping_identity != official_anchor.mapping_identity
|
||||
|| manifest.localized_mapping_identity.is_empty()
|
||||
|| manifest.destination_index.len() != manifest.entries.len()
|
||||
|| official_manifest.destination_index.len() != official_manifest.entries.len()
|
||||
@@ -1164,6 +1172,7 @@ fn list_official_releases(
|
||||
} else {
|
||||
artifact
|
||||
};
|
||||
let legacy_distribution = distribution_integrity_status == "legacy";
|
||||
releases.push(ReleaseSummary {
|
||||
channel: "official".to_string(),
|
||||
id,
|
||||
@@ -1179,7 +1188,8 @@ fn list_official_releases(
|
||||
referenced,
|
||||
unknown: manifest_contract_status == "missing"
|
||||
|| artifact_integrity_status == "unknown"
|
||||
|| distribution_integrity_status == "unknown",
|
||||
|| distribution_integrity_status == "unknown"
|
||||
|| distribution_integrity_status == "legacy",
|
||||
lifecycle: if current {
|
||||
if artifact_valid && pointer_valid {
|
||||
"current".to_string()
|
||||
@@ -1194,7 +1204,7 @@ fn list_official_releases(
|
||||
manifest_contract_status,
|
||||
artifact_integrity_status,
|
||||
distribution_integrity_status,
|
||||
legacy: false,
|
||||
legacy: legacy_distribution,
|
||||
rollback_previous_release_id,
|
||||
diagnostics,
|
||||
});
|
||||
@@ -1383,16 +1393,34 @@ fn verify_official_release(path: &Path) -> ((String, String, String), Vec<String
|
||||
diagnostics,
|
||||
);
|
||||
};
|
||||
if let Err(error) = verify_download_manifest(path, &manifest) {
|
||||
diagnostics.push(error.to_string());
|
||||
}
|
||||
let status = if diagnostics.is_empty() {
|
||||
"valid"
|
||||
} else {
|
||||
"invalid"
|
||||
let artifact_valid = match verify_download_manifest(path, &manifest) {
|
||||
Ok(()) => true,
|
||||
Err(error) => {
|
||||
diagnostics.push(error.to_string());
|
||||
false
|
||||
}
|
||||
};
|
||||
let expected_release_id = path
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or_default();
|
||||
let distribution_integrity_status =
|
||||
match verify_official_distribution_publication_at(path, expected_release_id) {
|
||||
Ok(Some(_)) if artifact_valid => "valid",
|
||||
Ok(None) if artifact_valid => "legacy",
|
||||
Ok(Some(_)) | Ok(None) => "invalid",
|
||||
Err(error) => {
|
||||
diagnostics.push(error);
|
||||
"invalid"
|
||||
}
|
||||
};
|
||||
let status = if artifact_valid { "valid" } else { "invalid" };
|
||||
(
|
||||
("valid".to_string(), status.to_string(), status.to_string()),
|
||||
(
|
||||
"valid".to_string(),
|
||||
status.to_string(),
|
||||
distribution_integrity_status.to_string(),
|
||||
),
|
||||
diagnostics,
|
||||
)
|
||||
}
|
||||
@@ -1401,21 +1429,6 @@ fn verify_download_manifest(
|
||||
root: &Path,
|
||||
manifest: &OfficialDownloadManifest,
|
||||
) -> anyhow::Result<()> {
|
||||
let expected_identity =
|
||||
crate::official_download::official_distribution_mapping_identity(manifest);
|
||||
if manifest.distribution_mapping_identity.as_deref() != Some(expected_identity.as_str()) {
|
||||
return Err(anyhow::anyhow!(
|
||||
"official distribution mapping identity mismatch"
|
||||
));
|
||||
}
|
||||
let expected_index =
|
||||
crate::official_download::official_distribution_destination_index(manifest)
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
if manifest.destination_index != expected_index {
|
||||
return Err(anyhow::anyhow!(
|
||||
"official distribution destination index mismatch"
|
||||
));
|
||||
}
|
||||
for entry in manifest.entries.values() {
|
||||
let path = safe_manifest_file_path(root, &entry.destination)?;
|
||||
let bytes = fs::read(&path)?;
|
||||
@@ -1434,6 +1447,14 @@ fn verify_full_distribution(official_root: &Path, localized_root: &Path) -> Stri
|
||||
let Ok(Some(manifest)) = read_download_manifest_at(official_root) else {
|
||||
return "unknown".to_string();
|
||||
};
|
||||
let Some(official_release_id) = official_root.file_name().and_then(|name| name.to_str()) else {
|
||||
return "unknown".to_string();
|
||||
};
|
||||
match verify_official_distribution_publication_at(official_root, official_release_id) {
|
||||
Ok(Some(_)) => {}
|
||||
Ok(None) => return "legacy".to_string(),
|
||||
Err(_) => return "invalid".to_string(),
|
||||
}
|
||||
if let Some(localized_release_id) = read_localized_patch_manifest_at(localized_root)
|
||||
.ok()
|
||||
.flatten()
|
||||
@@ -1578,18 +1599,31 @@ mod tests {
|
||||
let version = root.join(OFFICIAL_VERSIONS_DIR).join(id);
|
||||
fs::create_dir_all(&version).unwrap();
|
||||
let data = b"official";
|
||||
let other = b"official-other";
|
||||
fs::write(version.join("data.bin"), data).unwrap();
|
||||
fs::write(version.join("other.bin"), other).unwrap();
|
||||
let mut manifest = OfficialDownloadManifest {
|
||||
version: 1,
|
||||
entries: [(
|
||||
"https://example.invalid/data.bin".to_string(),
|
||||
OfficialDownloadManifestEntry {
|
||||
url: "https://example.invalid/data.bin".to_string(),
|
||||
destination: "data.bin".to_string(),
|
||||
bytes: data.len() as u64,
|
||||
blake3: blake3::hash(data).to_hex().to_string(),
|
||||
},
|
||||
)]
|
||||
entries: [
|
||||
(
|
||||
"https://example.invalid/data.bin".to_string(),
|
||||
OfficialDownloadManifestEntry {
|
||||
url: "https://example.invalid/data.bin".to_string(),
|
||||
destination: "data.bin".to_string(),
|
||||
bytes: data.len() as u64,
|
||||
blake3: blake3::hash(data).to_hex().to_string(),
|
||||
},
|
||||
),
|
||||
(
|
||||
"https://example.invalid/other.bin".to_string(),
|
||||
OfficialDownloadManifestEntry {
|
||||
url: "https://example.invalid/other.bin".to_string(),
|
||||
destination: "other.bin".to_string(),
|
||||
bytes: other.len() as u64,
|
||||
blake3: blake3::hash(other).to_hex().to_string(),
|
||||
},
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
destination_index: BTreeMap::new(),
|
||||
@@ -1604,6 +1638,8 @@ mod tests {
|
||||
serde_json::to_vec(&manifest).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
crate::official_download::write_official_distribution_publication_anchor_at(&version, id)
|
||||
.unwrap();
|
||||
version
|
||||
}
|
||||
|
||||
@@ -1661,12 +1697,31 @@ mod tests {
|
||||
assert!(!localized.unknown);
|
||||
assert_eq!(report.releases.len(), 2);
|
||||
|
||||
fs::remove_file(official_version.join(crate::OFFICIAL_DISTRIBUTION_PUBLICATION_FILE))
|
||||
.unwrap();
|
||||
let missing_publication = select_release_distribution(
|
||||
&official_root,
|
||||
&localized_root,
|
||||
&ReleaseDistributionParams {
|
||||
channel: Some("official".to_string()),
|
||||
..ReleaseDistributionParams::default()
|
||||
},
|
||||
Path::new("unzip"),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!missing_publication.available);
|
||||
crate::official_download::write_official_distribution_publication_anchor_at(
|
||||
&official_version,
|
||||
"official-v1",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let mut tampered = crate::read_download_manifest_at(&official_version)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
tampered
|
||||
.entries
|
||||
.get_mut("https://example.invalid/data.bin")
|
||||
.get_mut("https://example.invalid/other.bin")
|
||||
.unwrap()
|
||||
.url = "https://example.invalid/tampered.bin".to_string();
|
||||
fs::write(
|
||||
@@ -1696,6 +1751,18 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!blocked.available);
|
||||
let official_blocked = select_release_distribution(
|
||||
&official_root,
|
||||
&localized_root,
|
||||
&ReleaseDistributionParams {
|
||||
channel: Some("official".to_string()),
|
||||
destination: Some("data.bin".to_string()),
|
||||
..ReleaseDistributionParams::default()
|
||||
},
|
||||
Path::new("unzip"),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!official_blocked.available);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1762,11 +1829,7 @@ mod tests {
|
||||
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)
|
||||
},
|
||||
blake3: blake3::hash(&data).to_hex().to_string(),
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1785,6 +1848,11 @@ mod tests {
|
||||
serde_json::to_vec(&manifest).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
crate::official_download::write_official_distribution_publication_anchor_at(
|
||||
&version, "large",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(version.join("resource-0001.bin"), b"tampered").unwrap();
|
||||
fs::create_dir_all(&official_root).unwrap();
|
||||
symlink(
|
||||
Path::new(OFFICIAL_VERSIONS_DIR).join("large"),
|
||||
|
||||
Reference in New Issue
Block a user