1use std::collections::BTreeMap;
2use std::io::Read;
3use std::path::Path;
4use std::sync::Arc;
5
6use indicatif::ProgressBar;
7use indicatif::ProgressStyle;
8use rootcause::prelude::*;
9use wowsunpack::game_data;
10use wowsunpack::game_params::cache;
11use wowsunpack::game_params::provider::GameMetadataProvider;
12use wowsunpack::game_params::types::GameParamProvider;
13use wowsunpack::game_params::types::Param;
14use wowsunpack::vfs::VfsFileType;
15use wowsunpack::vfs::VfsPath;
16
17use crate::builds::BuildEntry;
18use crate::builds::BuildMetadata;
19use crate::builds::BuildsIndex;
20use crate::builds::CorruptObject;
21use crate::cas;
22
23const VFS_DIRS: &[&str] = &[
27 "gui/fla/minimap",
28 "gui/battle_hud",
29 "gui/consumables",
30 "gui/powerups/drops",
31 "gui/fonts",
32 "gui/data/constants",
33 "gui/ships_silhouettes",
34 "gui/ribbons",
35 "gui/achievements",
36 "gui/nation_flags",
37 "gui/crew_commander/skills",
38 "gui/modernization_icons",
39 "gui/signal_flags",
40 "scripts/entity_defs",
41];
42
43const REQUIRED_NONEMPTY_DIRS: &[&str] = &["scripts/entity_defs"];
47
48const REQUIRED_VFS_FILES: &[&str] = &["content/GameParams.data", "scripts/entities.xml"];
51
52const MAP_FILES_SPACES: &[&str] = &["minimap.png", "minimap_water.png", "space.settings"];
54
55const MAP_FILES_GAMEPLAY: &[&str] = &["space.settings"];
57
58pub fn required_path_globs() -> Vec<String> {
64 let mut globs = Vec::new();
65 for dir in VFS_DIRS {
66 globs.push(format!("{dir}/*"));
69 }
70 for file in REQUIRED_VFS_FILES {
71 globs.push((*file).to_string());
72 }
73 for name in MAP_FILES_SPACES {
74 globs.push(format!("spaces/*/{name}"));
75 }
76 for name in MAP_FILES_GAMEPLAY {
77 globs.push(format!("content/gameplay/*/{name}"));
78 }
79 globs
80}
81
82pub fn dump_dir(output_base: &Path, version_str: &str, build: u32) -> std::path::PathBuf {
84 output_base.join(format!("{version_str}_{build}"))
85}
86
87pub fn dump_exists(output_base: &Path, version_str: &str, build: u32) -> bool {
89 dump_dir(output_base, version_str, build).join("metadata.toml").exists()
90}
91
92pub fn dump_renderer_data(
101 game_dir: &Path,
102 build: u32,
103 version_str: &str,
104 output_base: &Path,
105 progress: Option<&ProgressBar>,
106 allow_existing: bool,
107) -> Result<(), Report> {
108 let output_dir = dump_dir(output_base, version_str, build);
109 let vfs_dir = output_dir.join("vfs");
110 let cas_root = cas::cas_root(output_base);
111
112 if output_dir.join("metadata.toml").exists() {
113 if allow_existing {
114 return Ok(());
115 }
116 bail!("Output directory already exists: {}", output_dir.display());
117 }
118
119 if output_dir.exists() {
121 std::fs::remove_dir_all(&output_dir)
122 .attach_with(|| format!("Failed to clean up partial dump at {}", output_dir.display()))?;
123 }
124
125 let vfs = game_data::build_game_vfs_for_build(game_dir, build).attach_with(|| "Failed to build game VFS")?;
126
127 let mut file_hashes: BTreeMap<String, String> = BTreeMap::new();
129
130 let mut dir_counts: BTreeMap<&str, usize> = BTreeMap::new();
131 for dir in VFS_DIRS {
132 let count = extract_vfs_dir_cas(&vfs, dir, &vfs_dir, &cas_root, &mut file_hashes, progress)?;
133 dir_counts.insert(dir, count);
134 }
135 let mut missing_files = Vec::new();
136 for file in REQUIRED_VFS_FILES {
137 if !extract_vfs_file_cas(&vfs, file, &vfs_dir, &cas_root, &mut file_hashes)? {
138 missing_files.push(*file);
139 }
140 if let Some(pb) = progress {
141 pb.inc(1);
142 }
143 }
144 let map_count =
145 extract_map_files_cas(&vfs, "spaces", MAP_FILES_SPACES, &vfs_dir, &cas_root, &mut file_hashes, progress)?;
146 extract_map_files_cas(
147 &vfs,
148 "content/gameplay",
149 MAP_FILES_GAMEPLAY,
150 &vfs_dir,
151 &cas_root,
152 &mut file_hashes,
153 progress,
154 )?;
155
156 if let Some(pb) = progress {
157 pb.finish_and_clear();
158 }
159
160 let mut problems = Vec::new();
164 if !missing_files.is_empty() {
165 problems.push(format!("missing required file(s): {}", missing_files.join(", ")));
166 }
167 for dir in REQUIRED_NONEMPTY_DIRS {
168 if dir_counts.get(dir).copied().unwrap_or(0) == 0 {
169 problems.push(format!("required directory '{dir}' extracted no files"));
170 }
171 }
172 if map_count == 0 {
173 problems.push(
174 "no map data extracted (spaces/*/minimap.png); the content depot's spaces packages are likely missing"
175 .to_string(),
176 );
177 }
178 if !problems.is_empty() {
179 let _ = std::fs::remove_dir_all(&output_dir);
180 bail!("Incomplete dump for build {build} ({version_str}): {}", problems.join("; "));
181 }
182 for dir in VFS_DIRS {
183 if dir_counts.get(dir).copied().unwrap_or(0) == 0 {
184 tracing::warn!("dump for build {build}: directory '{dir}' extracted no files");
185 }
186 }
187
188 std::fs::create_dir_all(&output_dir)
189 .attach_with(|| format!("Failed to create output directory {}", output_dir.display()))?;
190
191 dump_all_translations(game_dir, build, &output_dir)?;
192
193 #[cfg(feature = "constants")]
195 match crate::constants::ConstantsFetcher::new() {
196 Ok(fetcher) => {
197 write_constants_for_build(&output_dir, build, Some(version_str), &fetcher);
198 }
199 Err(e) => {
200 tracing::warn!("Could not initialize constants fetcher for build {build}: {e:?}");
201 }
202 }
203
204 let mut metadata =
208 BuildMetadata { version: version_str.to_string(), build, files: file_hashes, derived: BTreeMap::new() };
209 refresh_build_derived(&output_dir, &cas_root, &mut metadata)?;
210 metadata.save(&output_dir.join("metadata.toml"))?;
211
212 let builds_path = output_base.join("builds.toml");
214 let mut index = BuildsIndex::load(&builds_path);
215 index.upsert(BuildEntry {
216 version: version_str.to_string(),
217 build,
218 dir: format!("{version_str}_{build}"),
219 dumped_at: jiff::Zoned::now().to_string(),
220 });
221 index.save(&builds_path)?;
222
223 Ok(())
224}
225
226pub fn complete_build(game_dir: &Path, build: u32, output_base: &Path, with_gui: bool) -> Result<usize, Report> {
240 let index = BuildsIndex::load(&output_base.join("builds.toml"));
241 let entry = index
242 .find_by_build(build)
243 .ok_or_else(|| report!("build {build} is not in builds.toml; dump it normally first"))?
244 .clone();
245 let output_dir = output_base.join(&entry.dir);
246 let vfs_dir = output_dir.join("vfs");
247 let cas_root = cas::cas_root(output_base);
248 let meta_path = output_dir.join("metadata.toml");
249 let mut metadata =
250 BuildMetadata::load(&meta_path).ok_or_else(|| report!("{} has no readable metadata.toml", entry.dir))?;
251
252 let vfs = game_data::build_game_vfs_for_build(game_dir, build).attach_with(|| "Failed to build game VFS")?;
253
254 if with_gui {
255 for dir in VFS_DIRS.iter().filter(|d| d.starts_with("gui")) {
259 extract_vfs_dir_cas(&vfs, dir, &vfs_dir, &cas_root, &mut metadata.files, None)?;
260 }
261 }
262
263 let map_count =
264 extract_map_files_cas(&vfs, "spaces", MAP_FILES_SPACES, &vfs_dir, &cas_root, &mut metadata.files, None)?;
265 extract_map_files_cas(
266 &vfs,
267 "content/gameplay",
268 MAP_FILES_GAMEPLAY,
269 &vfs_dir,
270 &cas_root,
271 &mut metadata.files,
272 None,
273 )?;
274 if map_count == 0 {
275 bail!("no maps extracted for build {build}; refusing to record an incomplete build");
276 }
277
278 refresh_build_derived(&output_dir, &cas_root, &mut metadata)?;
281 metadata.save(&meta_path)?;
282 Ok(map_count)
283}
284
285pub fn create_progress_bar(game_dir: &Path) -> Option<ProgressBar> {
287 let vfs = game_data::build_game_vfs(game_dir).ok()?;
288 let mut total_files = 0u64;
289 for dir in VFS_DIRS {
290 total_files += count_vfs_dir_files(&vfs, dir);
291 }
292 total_files += REQUIRED_VFS_FILES.len() as u64;
293 total_files += count_map_files(&vfs, "spaces", MAP_FILES_SPACES);
294 total_files += count_map_files(&vfs, "content/gameplay", MAP_FILES_GAMEPLAY);
295
296 let pb = ProgressBar::new(total_files);
297 pb.set_style(
298 ProgressStyle::default_bar()
299 .template("{msg} [{bar:40}] {pos}/{len}")
300 .expect("valid template")
301 .progress_chars("=> "),
302 );
303 pb.set_message("Extracting VFS");
304 Some(pb)
305}
306
307pub fn remove_build(output_base: &Path, target_build: u32) -> Result<(), Report> {
309 let builds_path = output_base.join("builds.toml");
310 let mut index = BuildsIndex::load(&builds_path);
311 let entry = index
312 .find_by_build(target_build)
313 .ok_or_else(|| report!("Build {target_build} not found in builds.toml"))?
314 .clone();
315
316 let target_dir = output_base.join(&entry.dir);
317 let target_meta = BuildMetadata::load(&target_dir.join("metadata.toml"));
318
319 let mut live_hashes = std::collections::HashSet::new();
321 for other in &index.builds {
322 if other.build == target_build {
323 continue;
324 }
325 if let Some(meta) = BuildMetadata::load(&output_base.join(&other.dir).join("metadata.toml")) {
326 live_hashes.extend(meta.referenced_hashes());
327 }
328 }
329
330 if let Some(meta) = target_meta {
332 let cas_root = cas::cas_root(output_base);
333 for hash in meta.referenced_hashes() {
334 if !live_hashes.contains(&hash) {
335 let path = cas::cas_path(&cas_root, &hash);
336 let _ = std::fs::remove_file(&path);
337 }
338 }
339 let _ = cas::gc(&cas_root, &live_hashes);
341 }
342
343 if target_dir.exists() {
345 std::fs::remove_dir_all(&target_dir)
346 .attach_with(|| format!("Failed to remove build directory {}", target_dir.display()))?;
347 }
348
349 index.remove_build(target_build);
351 index.save(&builds_path)?;
352
353 Ok(())
354}
355
356pub enum SyncSelector {
360 All,
362 Latest,
364 Build(u32),
366 Version(String),
368}
369
370pub struct SyncedBuild {
372 pub build: u32,
373 pub version: String,
374 pub copied: bool,
376}
377
378pub fn sync_from_local(
387 source_base: &Path,
388 output_base: &Path,
389 selector: &SyncSelector,
390 force: bool,
391) -> Result<Vec<SyncedBuild>, Report> {
392 if source_base == output_base {
393 bail!("source and destination are the same directory");
394 }
395 let source_index = BuildsIndex::load(&source_base.join("builds.toml"));
396 if source_index.builds.is_empty() {
397 bail!("no builds.toml entries found in source {}", source_base.display());
398 }
399
400 let entries: Vec<BuildEntry> = match selector {
401 SyncSelector::All => source_index.builds.clone(),
402 SyncSelector::Latest => {
403 let latest =
404 source_index.builds.iter().max_by_key(|e| e.build).ok_or_else(|| report!("source has no builds"))?;
405 vec![latest.clone()]
406 }
407 SyncSelector::Build(b) => {
408 let entry = source_index
409 .find_by_build(*b)
410 .ok_or_else(|| report!("build {b} not found in source {}", source_base.display()))?;
411 vec![entry.clone()]
412 }
413 SyncSelector::Version(v) => {
414 let matches: Vec<BuildEntry> = source_index.find_by_version(v).into_iter().cloned().collect();
415 if matches.is_empty() {
416 bail!("no builds matching version '{v}' in source {}", source_base.display());
417 }
418 matches
419 }
420 };
421
422 let mut synced = Vec::new();
423 for entry in &entries {
424 let copied = copy_build_from_local(source_base, output_base, entry, force)?;
425 synced.push(SyncedBuild { build: entry.build, version: entry.version.clone(), copied });
426 }
427 Ok(synced)
428}
429
430fn copy_build_from_local(
433 source_base: &Path,
434 output_base: &Path,
435 entry: &BuildEntry,
436 force: bool,
437) -> Result<bool, Report> {
438 let src_cas = cas::cas_root(source_base);
439 let dst_cas = cas::cas_root(output_base);
440 let output_dir = output_base.join(&entry.dir);
441
442 if !force && output_dir.join("metadata.toml").exists() {
444 register_build(output_base, entry)?;
445 return Ok(false);
446 }
447
448 let src_dir = source_base.join(&entry.dir);
449 let meta_path = src_dir.join("metadata.toml");
450 let metadata = BuildMetadata::load(&meta_path)
451 .ok_or_else(|| report!("source build {} has no readable metadata.toml", entry.dir))?;
452
453 for hash in metadata.referenced_hashes() {
459 if cas::object_exists(&dst_cas, &hash) {
460 continue;
461 }
462 let src_obj = cas::cas_path(&src_cas, &hash);
463 let data =
464 std::fs::read(&src_obj).attach_with(|| format!("source content object {} missing", src_obj.display()))?;
465 let actual = cas::hash_bytes(&data);
466 if actual != hash {
467 bail!("source content object {hash} hashed to {actual}");
468 }
469 cas::store(&dst_cas, &data)?;
470 }
471
472 if output_dir.exists() {
474 std::fs::remove_dir_all(&output_dir)
475 .attach_with(|| format!("failed to clear destination build dir {}", output_dir.display()))?;
476 }
477
478 let vfs_dir = output_dir.join("vfs");
481 for (rel, hash) in &metadata.files {
482 cas::link_file(&dst_cas, hash, &vfs_dir.join(rel))?;
483 }
484 for (rel, hash) in &metadata.derived {
485 cas::link_file(&dst_cas, hash, &output_dir.join(rel))?;
486 }
487
488 let src_constants = src_dir.join("constants.json");
490 if src_constants.exists() {
491 let bytes =
492 std::fs::read(&src_constants).attach_with(|| format!("failed to read {}", src_constants.display()))?;
493 let dest = output_dir.join("constants.json");
494 std::fs::create_dir_all(dest.parent().unwrap())?;
495 std::fs::write(&dest, &bytes).attach_with(|| format!("failed to write {}", dest.display()))?;
496 }
497
498 metadata.save(&output_dir.join("metadata.toml"))?;
499 register_build(output_base, entry)?;
500 Ok(true)
501}
502
503fn register_build(output_base: &Path, entry: &BuildEntry) -> Result<(), Report> {
505 let builds_path = output_base.join("builds.toml");
506 let mut index = BuildsIndex::load(&builds_path);
507 index.upsert(entry.clone());
508 index.save(&builds_path)
509}
510
511fn dump_all_translations(game_dir: &Path, build: u32, output_dir: &Path) -> Result<(), Report> {
514 let texts_dir = game_dir.join("bin").join(build.to_string()).join("res/texts");
515 if !texts_dir.exists() {
516 tracing::warn!("Translations directory not found: {}", texts_dir.display());
517 return Ok(());
518 }
519 for entry in std::fs::read_dir(&texts_dir)
520 .attach_with(|| format!("Failed to read translations directory {}", texts_dir.display()))?
521 .flatten()
522 {
523 if !entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
524 continue;
525 }
526 let lang = entry.file_name();
527 let mo_src = entry.path().join("LC_MESSAGES/global.mo");
528 if mo_src.exists() {
529 let mo_dest = output_dir.join("translations").join(&lang).join("LC_MESSAGES/global.mo");
530 std::fs::create_dir_all(mo_dest.parent().unwrap())?;
531 std::fs::copy(&mo_src, &mo_dest)?;
532 }
533 }
534 Ok(())
535}
536
537fn store_and_link(
541 data: &[u8],
542 rel_path: &str,
543 vfs_dir: &Path,
544 cas_root: &Path,
545 file_hashes: &mut BTreeMap<String, String>,
546) -> Result<(), Report> {
547 let hash = cas::store(cas_root, data)?;
548 let link_path = vfs_dir.join(rel_path.trim_start_matches('/'));
549 cas::link_file(cas_root, &hash, &link_path)?;
550 file_hashes.insert(rel_path.trim_start_matches('/').to_string(), hash);
551 Ok(())
552}
553
554fn derive_game_params_rkyv(vfs_dir: &Path) -> Option<Vec<u8>> {
561 if !vfs_dir.join("content/GameParams.data").exists() {
562 return None;
563 }
564 let vfs = VfsPath::new(wowsunpack::vfs::PhysicalFS::new(vfs_dir));
565 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
566 let gmp = GameMetadataProvider::from_vfs(&vfs)?;
567 let params: Vec<Param> = gmp.params().iter().map(|p| Arc::unwrap_or_clone(Arc::clone(p))).collect();
568 cache::encode(¶ms).map_err(|e| report!("Failed to serialize: {e}"))
569 }));
570 match result {
571 Ok(Ok(bytes)) => Some(bytes),
572 Ok(Err(e)) => {
573 eprintln!("WARN: GameParams re-derivation failed for {}: {e:?}", vfs_dir.display());
574 None
575 }
576 Err(_) => {
577 eprintln!("WARN: GameParams re-derivation panicked for {} (incompatible pickle format)", vfs_dir.display(),);
578 None
579 }
580 }
581}
582
583fn store_and_relink(data: &[u8], link_path: &Path, cas_root: &Path) -> Result<String, Report> {
586 let hash = cas::store(cas_root, data)?;
587 let _ = std::fs::remove_file(link_path);
588 cas::link_file(cas_root, &hash, link_path)?;
589 Ok(hash)
590}
591
592pub fn refresh_build_derived(build_dir: &Path, cas_root: &Path, metadata: &mut BuildMetadata) -> Result<(), Report> {
600 metadata.derived.clear();
601
602 let rkyv_path = build_dir.join("game_params.rkyv");
603 let rkyv_bytes = derive_game_params_rkyv(&build_dir.join("vfs"));
604 let rkyv_bytes = match rkyv_bytes {
605 Some(b) => Some(b),
606 None if rkyv_path.exists() => {
607 Some(std::fs::read(&rkyv_path).attach_with(|| format!("Failed to read {}", rkyv_path.display()))?)
608 }
609 None => None,
610 };
611 if let Some(rkyv_bytes) = rkyv_bytes {
612 let hash = store_and_relink(&rkyv_bytes, &rkyv_path, cas_root)?;
613 metadata.derived.insert("game_params.rkyv".to_string(), hash);
614
615 let compressed =
616 ruzstd::encoding::compress_to_vec(rkyv_bytes.as_slice(), ruzstd::encoding::CompressionLevel::Fastest);
617 let zst_path = build_dir.join("game_params.rkyv.zst");
618 let hash = store_and_relink(&compressed, &zst_path, cas_root)?;
619 metadata.derived.insert("game_params.rkyv.zst".to_string(), hash);
620 }
621
622 let translations_dir = build_dir.join("translations");
626 if translations_dir.exists() {
627 for lang_entry in std::fs::read_dir(&translations_dir)
628 .attach_with(|| format!("Failed to read {}", translations_dir.display()))?
629 .flatten()
630 {
631 if !lang_entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
632 continue;
633 }
634 let lang = lang_entry.file_name().to_string_lossy().into_owned();
635 let mo_rel = format!("translations/{lang}/LC_MESSAGES/global.mo");
636 let mo_path = build_dir.join(&mo_rel);
637 if !mo_path.exists() {
638 continue;
639 }
640 let bytes = std::fs::read(&mo_path).attach_with(|| format!("Failed to read {}", mo_path.display()))?;
641 let hash = store_and_relink(&bytes, &mo_path, cas_root)?;
642 metadata.derived.insert(mo_rel, hash);
643 }
644 }
645
646 let mo_rel = "translations/en/LC_MESSAGES/global.mo";
648 let mo_path = build_dir.join(mo_rel);
649 if mo_path.exists() {
650 let mo_bytes = std::fs::read(&mo_path).attach_with(|| format!("Failed to read {}", mo_path.display()))?;
651 let compressed =
652 ruzstd::encoding::compress_to_vec(mo_bytes.as_slice(), ruzstd::encoding::CompressionLevel::Fastest);
653 let zst_path = build_dir.join(format!("{mo_rel}.zst"));
654 let hash = store_and_relink(&compressed, &zst_path, cas_root)?;
655 metadata.derived.insert(format!("{mo_rel}.zst"), hash);
656 }
657
658 Ok(())
659}
660
661pub fn refresh_derived(output_base: &Path, only_build: Option<u32>) -> Result<(), Report> {
665 let index = BuildsIndex::load(&output_base.join("builds.toml"));
666 let cas_root = cas::cas_root(output_base);
667
668 let targets: Vec<&BuildEntry> = match only_build {
669 Some(b) => index.builds.iter().filter(|e| e.build == b).collect(),
670 None => index.builds.iter().collect(),
671 };
672 if targets.is_empty() {
673 bail!("No matching builds found in {}", output_base.join("builds.toml").display());
674 }
675
676 #[cfg(feature = "constants")]
680 let constants_fetcher = match crate::constants::ConstantsFetcher::new() {
681 Ok(f) => Some(f),
682 Err(e) => {
683 eprintln!("WARN: Could not initialize constants fetcher: {e:?}");
684 None
685 }
686 };
687
688 for entry in &targets {
689 let build_dir = output_base.join(&entry.dir);
690 let meta_path = build_dir.join("metadata.toml");
691 let mut metadata = BuildMetadata::load(&meta_path).unwrap_or(BuildMetadata {
692 version: entry.version.clone(),
693 build: entry.build,
694 ..Default::default()
695 });
696
697 #[cfg(feature = "constants")]
698 let constants_added = if let Some(fetcher) = constants_fetcher.as_ref() {
699 update_constants_if_missing(&build_dir, entry.build, Some(entry.version.as_str()), fetcher)
700 } else {
701 false
702 };
703
704 match refresh_build_derived(&build_dir, &cas_root, &mut metadata) {
705 Ok(()) => {
706 metadata.save(&meta_path)?;
707 #[cfg(feature = "constants")]
708 let constants_note = if constants_added { " + constants" } else { "" };
709 #[cfg(not(feature = "constants"))]
710 let constants_note = "";
711 println!(" {} - {} derived artifact(s){}", entry.dir, metadata.derived.len(), constants_note);
712 }
713 Err(e) => eprintln!("WARN: {} - failed to refresh derived data: {e:?}", entry.dir),
714 }
715 }
716
717 println!("Refreshed {} build(s).", targets.len());
718 Ok(())
719}
720
721#[cfg(feature = "constants")]
725fn write_constants_for_build(
726 build_dir: &Path,
727 build: u32,
728 version: Option<&str>,
729 fetcher: &crate::constants::ConstantsFetcher,
730) -> bool {
731 let Some((data, actual_build)) = fetcher.fetch(build, version) else {
732 tracing::warn!("No upstream constants available for build {build}");
733 return false;
734 };
735 let bytes = match serde_json::to_vec_pretty(&data) {
736 Ok(b) => b,
737 Err(e) => {
738 tracing::warn!("Failed to serialize constants for build {build}: {e}");
739 return false;
740 }
741 };
742 if let Err(e) = std::fs::write(build_dir.join("constants.json"), &bytes) {
743 tracing::warn!("Failed to write constants.json for build {build}: {e}");
744 return false;
745 }
746 if actual_build != build {
747 tracing::info!("Stored constants from build {actual_build} (fallback for {build})");
748 }
749 true
750}
751
752#[cfg(feature = "constants")]
757fn update_constants_if_missing(
758 build_dir: &Path,
759 build: u32,
760 version: Option<&str>,
761 fetcher: &crate::constants::ConstantsFetcher,
762) -> bool {
763 if build_dir.join("constants.json").exists() {
764 return false;
765 }
766 write_constants_for_build(build_dir, build, version, fetcher)
767}
768
769pub fn gc_cas(output_base: &Path) -> Result<(), Report> {
774 let index = BuildsIndex::load(&output_base.join("builds.toml"));
775 let cas_root = cas::cas_root(output_base);
776
777 let mut live = std::collections::HashSet::new();
778 for entry in &index.builds {
779 let meta_path = output_base.join(&entry.dir).join("metadata.toml");
780 let meta = BuildMetadata::load(&meta_path)
781 .ok_or_else(|| report!("{} has no readable metadata.toml; aborting GC", entry.dir))?;
782 live.extend(meta.referenced_hashes());
783 }
784
785 let removed = cas::gc(&cas_root, &live)?;
786 println!("GC removed {removed} orphaned CAS object(s); {} still referenced.", live.len());
787 Ok(())
788}
789
790pub struct BuildVerification {
792 pub dir: String,
793 pub build: u32,
794 pub version: String,
795 pub referenced: usize,
797 pub missing_objects: Vec<String>,
799 pub corrupt_objects: Vec<CorruptObject>,
802 pub broken_links: Vec<String>,
804 pub metadata_unreadable: bool,
806}
807
808impl BuildVerification {
809 pub fn is_ok(&self) -> bool {
810 !self.metadata_unreadable
811 && self.missing_objects.is_empty()
812 && self.corrupt_objects.is_empty()
813 && self.broken_links.is_empty()
814 }
815}
816
817#[derive(Debug, Clone, PartialEq, Eq)]
819pub enum ObjectAudit {
820 Intact,
822 Absent,
824 Corrupt { actual: String },
826}
827
828fn audit_objects(
835 builds: &[&BuildMetadata],
836 mut audit: impl FnMut(&str) -> ObjectAudit,
837) -> BTreeMap<String, ObjectAudit> {
838 let mut verdicts: BTreeMap<String, ObjectAudit> = BTreeMap::new();
839 for meta in builds {
840 for hash in meta.referenced_hashes() {
841 if verdicts.contains_key(&hash) {
842 continue;
843 }
844 let verdict = audit(&hash);
845 verdicts.insert(hash, verdict);
846 }
847 }
848 verdicts
849}
850
851fn audit_object_on_disk(cas_root: &Path, hash: &str) -> ObjectAudit {
854 let path = cas::cas_path(cas_root, hash);
855 match cas::hash_file(&path) {
856 Ok(actual) if actual == hash => ObjectAudit::Intact,
857 Ok(actual) => ObjectAudit::Corrupt { actual },
858 Err(e) if e.kind() == std::io::ErrorKind::NotFound => ObjectAudit::Absent,
859 Err(e) => {
860 tracing::warn!("could not read content object {}: {e}", path.display());
861 ObjectAudit::Absent
862 }
863 }
864}
865
866pub fn verify_builds(
876 output_base: &Path,
877 check_links: bool,
878 check_hashes: bool,
879) -> Result<Vec<BuildVerification>, Report> {
880 let cas_root = cas::cas_root(output_base);
881 verify_builds_audited(output_base, check_links, check_hashes, |hash| audit_object_on_disk(&cas_root, hash))
882}
883
884fn verify_builds_audited(
890 output_base: &Path,
891 check_links: bool,
892 check_hashes: bool,
893 audit: impl FnMut(&str) -> ObjectAudit,
894) -> Result<Vec<BuildVerification>, Report> {
895 let index = BuildsIndex::load(&output_base.join("builds.toml"));
896 let cas_root = cas::cas_root(output_base);
897
898 let loaded: Vec<(&BuildEntry, Option<BuildMetadata>)> = index
899 .builds
900 .iter()
901 .map(|entry| (entry, BuildMetadata::load(&output_base.join(&entry.dir).join("metadata.toml"))))
902 .collect();
903
904 let audits = if check_hashes {
905 let readable: Vec<&BuildMetadata> = loaded.iter().filter_map(|(_, meta)| meta.as_ref()).collect();
906 audit_objects(&readable, audit)
907 } else {
908 BTreeMap::new()
909 };
910
911 let mut reports = Vec::new();
912 for (entry, meta) in loaded {
913 let build_dir = output_base.join(&entry.dir);
914 let Some(meta) = meta else {
915 reports.push(BuildVerification {
916 dir: entry.dir.clone(),
917 build: entry.build,
918 version: entry.version.clone(),
919 referenced: 0,
920 missing_objects: Vec::new(),
921 corrupt_objects: Vec::new(),
922 broken_links: Vec::new(),
923 metadata_unreadable: true,
924 });
925 continue;
926 };
927
928 let referenced = meta.referenced_hashes();
929 let mut missing_objects = Vec::new();
930 let mut corrupt_objects = Vec::new();
931 for hash in &referenced {
932 match audits.get(hash) {
933 Some(ObjectAudit::Intact) => {}
934 Some(ObjectAudit::Absent) => missing_objects.push(hash.clone()),
935 Some(ObjectAudit::Corrupt { actual }) => {
936 corrupt_objects.push(CorruptObject::attribute(entry, &meta, hash, actual));
937 }
938 None => {
940 if !cas::object_exists(&cas_root, hash) {
941 missing_objects.push(hash.clone());
942 }
943 }
944 }
945 }
946 missing_objects.sort();
947 corrupt_objects.sort_by(|a, b| a.hash.cmp(&b.hash));
948
949 let mut broken_links = Vec::new();
950 if check_links {
951 for (rel, _) in meta.files.iter().chain(meta.derived.iter()) {
952 let path = build_dir.join("vfs").join(rel);
953 let candidate = if path.exists() { path } else { build_dir.join(rel) };
955 if !candidate.exists() {
956 broken_links.push(rel.clone());
957 }
958 }
959 broken_links.sort();
960 }
961
962 reports.push(BuildVerification {
963 dir: entry.dir.clone(),
964 build: entry.build,
965 version: entry.version.clone(),
966 referenced: referenced.len(),
967 missing_objects,
968 corrupt_objects,
969 broken_links,
970 metadata_unreadable: false,
971 });
972 }
973 Ok(reports)
974}
975
976pub fn gc_unreferenced(output_base: &Path) -> Result<usize, Report> {
983 let cas_root = cas::cas_root(output_base);
984 if !cas_root.exists() {
985 return Ok(0);
986 }
987
988 let mut live = std::collections::HashSet::new();
989 for entry in
990 std::fs::read_dir(output_base).attach_with(|| format!("Failed to read {}", output_base.display()))?.flatten()
991 {
992 let meta_path = entry.path().join("metadata.toml");
993 if !meta_path.exists() {
994 continue;
995 }
996 match BuildMetadata::load(&meta_path) {
997 Some(meta) => live.extend(meta.referenced_hashes()),
998 None => bail!("unreadable metadata at {}; aborting GC", meta_path.display()),
999 }
1000 }
1001
1002 cas::gc(&cas_root, &live)
1003}
1004
1005pub fn migrate_cas_dir_name(output_base: &Path) -> Result<bool, Report> {
1011 let legacy = output_base.join(cas::LEGACY_CAS_DIR);
1012 let current = cas::cas_root(output_base);
1013 if !legacy.exists() {
1014 return Ok(false);
1015 }
1016
1017 if !current.exists() {
1018 std::fs::rename(&legacy, ¤t)
1020 .attach_with(|| format!("failed to rename {} to {}", legacy.display(), current.display()))?;
1021 } else {
1022 merge_cas_objects(&legacy, ¤t)?;
1026 }
1027
1028 relink_all_builds(output_base, ¤t)?;
1031
1032 if legacy.exists() {
1035 std::fs::remove_dir_all(&legacy)
1036 .attach_with(|| format!("failed to remove emptied legacy store {}", legacy.display()))?;
1037 }
1038
1039 Ok(true)
1040}
1041
1042fn merge_cas_objects(legacy: &Path, dest: &Path) -> Result<(), Report> {
1046 for fanout in std::fs::read_dir(legacy).attach_with(|| format!("Failed to read {}", legacy.display()))?.flatten() {
1047 if !fanout.file_type().map(|t| t.is_dir()).unwrap_or(false) {
1048 continue;
1049 }
1050 let dest_fanout = dest.join(fanout.file_name());
1051 for obj in std::fs::read_dir(fanout.path())?.flatten() {
1052 if !obj.file_type().map(|t| t.is_file()).unwrap_or(false) {
1053 continue;
1054 }
1055 let dest_path = dest_fanout.join(obj.file_name());
1056 if dest_path.exists() {
1057 std::fs::remove_file(obj.path())?;
1058 continue;
1059 }
1060 std::fs::create_dir_all(&dest_fanout)?;
1061 if std::fs::rename(obj.path(), &dest_path).is_err() {
1063 std::fs::copy(obj.path(), &dest_path)?;
1064 std::fs::remove_file(obj.path())?;
1065 }
1066 }
1067 }
1068 Ok(())
1069}
1070
1071fn relink_all_builds(output_base: &Path, cas_root: &Path) -> Result<(), Report> {
1074 for entry in
1075 std::fs::read_dir(output_base).attach_with(|| format!("Failed to read {}", output_base.display()))?.flatten()
1076 {
1077 let build_dir = entry.path();
1078 let Some(meta) = BuildMetadata::load(&build_dir.join("metadata.toml")) else {
1079 continue;
1080 };
1081 let vfs_dir = build_dir.join("vfs");
1082 let relink = |rel: &str, hash: &str, base: &Path| {
1083 let link = base.join(rel);
1084 let _ = std::fs::remove_file(&link);
1085 if let Err(e) = cas::link_file(cas_root, hash, &link) {
1086 tracing::warn!("failed to relink {}: {e}", link.display());
1087 }
1088 };
1089 for (rel, hash) in &meta.files {
1090 relink(rel, hash, &vfs_dir);
1091 }
1092 for (rel, hash) in &meta.derived {
1093 relink(rel, hash, &build_dir);
1094 }
1095 }
1096 Ok(())
1097}
1098
1099pub fn migrate_to_cas(output_base: &Path) -> Result<usize, Report> {
1107 let cas_root = cas::cas_root(output_base);
1108 let mut migrated = 0;
1109 for entry in
1110 std::fs::read_dir(output_base).attach_with(|| format!("Failed to read {}", output_base.display()))?.flatten()
1111 {
1112 let build_dir = entry.path();
1113 let meta_path = build_dir.join("metadata.toml");
1114 let Some(mut metadata) = BuildMetadata::load(&meta_path) else {
1115 continue;
1116 };
1117 if metadata.has_file_hashes() || !build_dir.join("vfs").exists() {
1118 continue;
1119 }
1120 match migrate_build_to_cas(&build_dir, &cas_root, &mut metadata) {
1121 Ok(()) => {
1122 metadata.save(&meta_path)?;
1123 migrated += 1;
1124 }
1125 Err(e) => tracing::warn!("failed to migrate {} to CAS: {e}", build_dir.display()),
1126 }
1127 }
1128 Ok(migrated)
1129}
1130
1131fn migrate_build_to_cas(build_dir: &Path, cas_root: &Path, metadata: &mut BuildMetadata) -> Result<(), Report> {
1134 let vfs_dir = build_dir.join("vfs");
1135 let mut stack = vec![vfs_dir.clone()];
1136 while let Some(dir) = stack.pop() {
1137 for entry in std::fs::read_dir(&dir).attach_with(|| format!("Failed to read {}", dir.display()))?.flatten() {
1138 let path = entry.path();
1139 let file_type = entry.file_type().attach_with(|| format!("Failed to stat {}", path.display()))?;
1140 if file_type.is_dir() {
1141 stack.push(path);
1142 continue;
1143 }
1144 if file_type.is_symlink() {
1146 continue;
1147 }
1148 let rel =
1149 path.strip_prefix(&vfs_dir).expect("walked path is under vfs_dir").to_string_lossy().replace('\\', "/");
1150 let data = std::fs::read(&path).attach_with(|| format!("Failed to read {}", path.display()))?;
1151 let hash = cas::store(cas_root, &data)?;
1152 std::fs::remove_file(&path).attach_with(|| format!("Failed to remove {}", path.display()))?;
1153 cas::link_file(cas_root, &hash, &path)?;
1154 metadata.files.insert(rel, hash);
1155 }
1156 }
1157 refresh_build_derived(build_dir, cas_root, metadata)
1158}
1159
1160fn extract_vfs_dir_cas(
1161 vfs: &VfsPath,
1162 vfs_path: &str,
1163 vfs_dir: &Path,
1164 cas_root: &Path,
1165 file_hashes: &mut BTreeMap<String, String>,
1166 progress: Option<&ProgressBar>,
1167) -> Result<usize, Report> {
1168 let dir = match vfs.join(vfs_path) {
1169 Ok(d) => d,
1170 Err(_) => return Ok(0),
1171 };
1172 let walker = match dir.walk_dir() {
1173 Ok(w) => w,
1174 Err(_) => return Ok(0),
1175 };
1176
1177 let mut count = 0;
1178 for entry in walker.flatten() {
1179 let metadata = match entry.metadata() {
1180 Ok(m) => m,
1181 Err(_) => continue,
1182 };
1183 if metadata.file_type != VfsFileType::File {
1184 continue;
1185 }
1186 let rel = entry.as_str();
1187 let mut buf = Vec::new();
1188 match entry.open_file() {
1189 Ok(mut f) => f.read_to_end(&mut buf)?,
1190 Err(e) => {
1191 tracing::warn!("Failed to open VFS file {rel}: {e}");
1192 continue;
1193 }
1194 };
1195 store_and_link(&buf, rel, vfs_dir, cas_root, file_hashes)?;
1196 count += 1;
1197 if let Some(pb) = progress {
1198 pb.inc(1);
1199 }
1200 }
1201 Ok(count)
1202}
1203
1204fn extract_vfs_file_cas(
1206 vfs: &VfsPath,
1207 vfs_path: &str,
1208 vfs_dir: &Path,
1209 cas_root: &Path,
1210 file_hashes: &mut BTreeMap<String, String>,
1211) -> Result<bool, Report> {
1212 let file = match vfs.join(vfs_path) {
1213 Ok(f) => f,
1214 Err(_) => {
1215 tracing::warn!("VFS path not found (skipping): {vfs_path}");
1216 return Ok(false);
1217 }
1218 };
1219 let mut buf = Vec::new();
1220 match file.open_file() {
1221 Ok(mut f) => f.read_to_end(&mut buf)?,
1222 Err(_) => {
1223 tracing::warn!("Could not open VFS file (skipping): {vfs_path}");
1224 return Ok(false);
1225 }
1226 };
1227 store_and_link(&buf, vfs_path, vfs_dir, cas_root, file_hashes)?;
1228 Ok(true)
1229}
1230
1231fn extract_map_files_cas(
1234 vfs: &VfsPath,
1235 parent_dir: &str,
1236 filenames: &[&str],
1237 vfs_dir: &Path,
1238 cas_root: &Path,
1239 file_hashes: &mut BTreeMap<String, String>,
1240 progress: Option<&ProgressBar>,
1241) -> Result<usize, Report> {
1242 let parent = match vfs.join(parent_dir) {
1243 Ok(d) => d,
1244 Err(_) => return Ok(0),
1245 };
1246 let entries = match parent.read_dir() {
1247 Ok(e) => e,
1248 Err(_) => return Ok(0),
1249 };
1250
1251 let mut count = 0;
1252 for entry in entries {
1253 if !entry.metadata().map(|m| m.file_type == VfsFileType::Directory).unwrap_or(false) {
1254 continue;
1255 }
1256 for filename in filenames {
1257 let file_path = match entry.join(filename) {
1258 Ok(f) => f,
1259 Err(_) => continue,
1260 };
1261 if !file_path.exists().unwrap_or(false) {
1262 continue;
1263 }
1264 let rel = file_path.as_str();
1265 let mut buf = Vec::new();
1266 match file_path.open_file() {
1267 Ok(mut f) => f.read_to_end(&mut buf)?,
1268 Err(e) => {
1269 tracing::warn!("Failed to open VFS file {rel}: {e}");
1270 continue;
1271 }
1272 };
1273 store_and_link(&buf, rel, vfs_dir, cas_root, file_hashes)?;
1274 count += 1;
1275 if let Some(pb) = progress {
1276 pb.inc(1);
1277 }
1278 }
1279 }
1280 Ok(count)
1281}
1282
1283fn count_vfs_dir_files(vfs: &VfsPath, dir: &str) -> u64 {
1286 let mut count = 0;
1287 if let Ok(vfs_dir_path) = vfs.join(dir)
1288 && let Ok(walker) = vfs_dir_path.walk_dir()
1289 {
1290 for entry in walker.flatten() {
1291 if entry.metadata().map(|m| m.file_type == VfsFileType::File).unwrap_or(false) {
1292 count += 1;
1293 }
1294 }
1295 }
1296 count
1297}
1298
1299fn count_map_files(vfs: &VfsPath, parent_dir: &str, filenames: &[&str]) -> u64 {
1300 let mut count = 0;
1301 if let Ok(parent) = vfs.join(parent_dir)
1302 && let Ok(entries) = parent.read_dir()
1303 {
1304 for entry in entries {
1305 if entry.metadata().map(|m| m.file_type == VfsFileType::Directory).unwrap_or(false) {
1306 for filename in filenames {
1307 if entry.join(filename).is_ok_and(|f: VfsPath| f.exists().unwrap_or(false)) {
1308 count += 1;
1309 }
1310 }
1311 }
1312 }
1313 }
1314 count
1315}
1316
1317#[cfg(test)]
1318mod maintenance_tests {
1319 use super::*;
1320
1321 fn write_build_metadata(build_dir: &Path, version: &str, build: u32, files: &[(&str, &str)]) {
1322 std::fs::create_dir_all(build_dir).unwrap();
1323 let mut meta = BuildMetadata { version: version.to_string(), build, ..Default::default() };
1324 for (rel, hash) in files {
1325 meta.files.insert((*rel).to_string(), (*hash).to_string());
1326 }
1327 meta.save(&build_dir.join("metadata.toml")).unwrap();
1328 }
1329
1330 fn register(base: &Path, version: &str, build: u32) {
1331 let path = base.join("builds.toml");
1332 let mut index = BuildsIndex::load(&path);
1333 index.upsert(BuildEntry {
1334 version: version.to_string(),
1335 build,
1336 dir: format!("{version}_{build}"),
1337 dumped_at: String::new(),
1338 });
1339 index.save(&path).unwrap();
1340 }
1341
1342 #[test]
1346 fn hash_checking_finds_an_object_whose_bytes_were_changed() {
1347 let dir = tempfile::tempdir().unwrap();
1348 let base = dir.path();
1349 let cas_root = base.join("common");
1350
1351 let intact = cas::store(&cas_root, b"an object nobody touched").unwrap();
1352 let tampered = cas::store(&cas_root, b"<xml>\r\n <node />\r\n</xml>\r\n").unwrap();
1353 write_build_metadata(
1354 &base.join("15.4.0_12506899"),
1355 "15.4.0",
1356 12506899,
1357 &[("res/a.xml", &intact), ("res/b.xml", &tampered), ("res/c.xml", &tampered)],
1358 );
1359 register(base, "15.4.0", 12506899);
1360
1361 let rewritten: &[u8] = b"<xml>\n <node />\n</xml>\n";
1362 std::fs::write(cas::cas_path(&cas_root, &tampered), rewritten).unwrap();
1363
1364 let unchecked = verify_builds(base, false, false).unwrap();
1365 assert!(unchecked[0].is_ok(), "existence checking cannot see rewritten bytes");
1366
1367 let checked = verify_builds(base, false, true).unwrap();
1368 let report = &checked[0];
1369 assert!(!report.is_ok(), "hash checking must reject the rewritten object");
1370 assert!(report.missing_objects.is_empty(), "the object is present, just wrong");
1371 assert_eq!(report.corrupt_objects.len(), 1, "only the rewritten object is corrupt");
1372
1373 let corrupt = &report.corrupt_objects[0];
1374 assert_eq!(corrupt.hash, tampered);
1375 assert_eq!(corrupt.actual, cas::hash_bytes(rewritten));
1376 assert_eq!(corrupt.build, 12506899);
1377 assert_eq!(corrupt.version, "15.4.0");
1378 assert_eq!(corrupt.files, vec!["res/b.xml", "res/c.xml"]);
1379 }
1380
1381 #[test]
1385 fn an_object_is_hashed_once_even_when_several_builds_reference_it() {
1386 let mut first = BuildMetadata { version: "15.3.0".into(), build: 12400000, ..Default::default() };
1387 first.files.insert("res/a.xml".into(), "shared".into());
1388 first.files.insert("res/only_in_first.xml".into(), "unique".into());
1389 let mut second = BuildMetadata { version: "15.4.0".into(), build: 12506899, ..Default::default() };
1390 second.files.insert("res/b.xml".into(), "shared".into());
1391 second.derived.insert("GameParams.rkyv".into(), "shared".into());
1392
1393 let mut reads: Vec<String> = Vec::new();
1394 let verdicts = audit_objects(&[&first, &second], |hash| {
1395 reads.push(hash.to_string());
1396 ObjectAudit::Intact
1397 });
1398
1399 assert_eq!(reads.iter().filter(|h| h.as_str() == "shared").count(), 1, "read {reads:?}");
1400 assert_eq!(reads.len(), 2, "read {reads:?}");
1401 assert_eq!(verdicts.len(), 2);
1402 }
1403
1404 #[test]
1410 fn verifying_a_whole_dump_base_hashes_each_shared_object_once() {
1411 let dir = tempfile::tempdir().unwrap();
1412 let base = dir.path();
1413
1414 for (version, build, own) in [("15.3.0", 12400000u32, "only_in_first"), ("15.4.0", 12506899, "only_in_second")]
1415 {
1416 write_build_metadata(
1417 &base.join(format!("{version}_{build}")),
1418 version,
1419 build,
1420 &[("res/shared.xml", "shared"), ("res/own.xml", own)],
1421 );
1422 register(base, version, build);
1423 }
1424
1425 let mut reads: Vec<String> = Vec::new();
1426 let reports = verify_builds_audited(base, false, true, |hash| {
1427 reads.push(hash.to_string());
1428 ObjectAudit::Intact
1429 })
1430 .unwrap();
1431
1432 assert_eq!(reports.len(), 2);
1433 assert_eq!(reads.iter().filter(|h| h.as_str() == "shared").count(), 1, "read {reads:?}");
1434 assert_eq!(reads.len(), 3, "read {reads:?}");
1435 }
1436
1437 #[test]
1441 fn verifying_without_hash_checking_reads_no_object() {
1442 let dir = tempfile::tempdir().unwrap();
1443 let base = dir.path();
1444 write_build_metadata(&base.join("15.4.0_12506899"), "15.4.0", 12506899, &[("res/a.xml", "shared")]);
1445 register(base, "15.4.0", 12506899);
1446
1447 let mut reads: Vec<String> = Vec::new();
1448 let reports = verify_builds_audited(base, false, false, |hash| {
1449 reads.push(hash.to_string());
1450 ObjectAudit::Intact
1451 })
1452 .unwrap();
1453
1454 assert_eq!(reports.len(), 1);
1455 assert!(reads.is_empty(), "read {reads:?}");
1456 assert!(reports[0].corrupt_objects.is_empty(), "nothing was hashed, so nothing can be called corrupt");
1457 }
1458
1459 #[test]
1462 fn a_shared_corrupt_object_is_reported_against_every_build_that_references_it() {
1463 let dir = tempfile::tempdir().unwrap();
1464 let base = dir.path();
1465 let cas_root = base.join("common");
1466
1467 let shared = cas::store(&cas_root, b"shared between two builds\r\n").unwrap();
1468 write_build_metadata(&base.join("15.3.0_12400000"), "15.3.0", 12400000, &[("res/a.xml", &shared)]);
1469 write_build_metadata(&base.join("15.4.0_12506899"), "15.4.0", 12506899, &[("res/b.xml", &shared)]);
1470 register(base, "15.3.0", 12400000);
1471 register(base, "15.4.0", 12506899);
1472
1473 std::fs::write(cas::cas_path(&cas_root, &shared), b"rewritten\n").unwrap();
1474
1475 let reports = verify_builds(base, false, true).unwrap();
1476 assert_eq!(reports.len(), 2);
1477 for report in &reports {
1478 assert_eq!(report.corrupt_objects.len(), 1, "{} missed the corrupt object", report.dir);
1479 assert_eq!(report.corrupt_objects[0].hash, shared);
1480 }
1481 assert_eq!(reports[0].corrupt_objects[0].files, vec!["res/a.xml"]);
1482 assert_eq!(reports[1].corrupt_objects[0].files, vec!["res/b.xml"]);
1483 }
1484
1485 #[test]
1488 fn a_missing_object_is_not_reported_as_corrupt() {
1489 let dir = tempfile::tempdir().unwrap();
1490 let base = dir.path();
1491
1492 write_build_metadata(
1493 &base.join("15.4.0_12506899"),
1494 "15.4.0",
1495 12506899,
1496 &[("res/a.xml", "0123456789abcdef0123")],
1497 );
1498 register(base, "15.4.0", 12506899);
1499
1500 let reports = verify_builds(base, false, true).unwrap();
1501 assert_eq!(reports[0].missing_objects, vec!["0123456789abcdef0123"]);
1502 assert!(reports[0].corrupt_objects.is_empty());
1503 }
1504
1505 #[test]
1506 fn gc_unreferenced_removes_orphans_and_keeps_live() {
1507 let dir = tempfile::tempdir().unwrap();
1508 let base = dir.path();
1509 let cas_root = base.join("common");
1510
1511 let live_hash = cas::store(&cas_root, b"live object").unwrap();
1512 let orphan_hash = cas::store(&cas_root, b"orphan object").unwrap();
1513 write_build_metadata(&base.join("1.0.0_100"), "1.0.0", 100, &[("gui/a.png", &live_hash)]);
1514
1515 let removed = gc_unreferenced(base).unwrap();
1516 assert_eq!(removed, 1);
1517 assert!(cas::object_exists(&cas_root, &live_hash));
1518 assert!(!cas::object_exists(&cas_root, &orphan_hash));
1519 }
1520
1521 #[test]
1522 fn gc_unreferenced_aborts_on_unreadable_metadata() {
1523 let dir = tempfile::tempdir().unwrap();
1524 let base = dir.path();
1525 let cas_root = base.join("common");
1526 let orphan_hash = cas::store(&cas_root, b"orphan object").unwrap();
1527
1528 let build_dir = base.join("1.0.0_100");
1529 std::fs::create_dir_all(&build_dir).unwrap();
1530 std::fs::write(build_dir.join("metadata.toml"), b"this is not valid toml = =").unwrap();
1531
1532 assert!(gc_unreferenced(base).is_err());
1533 assert!(cas::object_exists(&cas_root, &orphan_hash));
1535 }
1536
1537 #[test]
1538 fn migrate_to_cas_dedups_plain_files() {
1539 let dir = tempfile::tempdir().unwrap();
1540 let base = dir.path();
1541 let build_dir = base.join("1.0.0_100");
1542
1543 write_build_metadata(&build_dir, "1.0.0", 100, &[]);
1545 let file_path = build_dir.join("vfs/gui/a.png");
1546 std::fs::create_dir_all(file_path.parent().unwrap()).unwrap();
1547 std::fs::write(&file_path, b"some asset bytes").unwrap();
1548
1549 let migrated = migrate_to_cas(base).unwrap();
1550 assert_eq!(migrated, 1);
1551
1552 assert!(std::fs::symlink_metadata(&file_path).unwrap().file_type().is_symlink());
1554 assert_eq!(std::fs::read(&file_path).unwrap(), b"some asset bytes");
1555
1556 let meta = BuildMetadata::load(&build_dir.join("metadata.toml")).unwrap();
1558 assert!(meta.has_file_hashes());
1559 assert!(meta.files.contains_key("gui/a.png"));
1560 assert_eq!(migrate_to_cas(base).unwrap(), 0);
1561 }
1562
1563 #[test]
1564 fn migrate_cas_dir_name_renames_and_relinks() {
1565 let dir = tempfile::tempdir().unwrap();
1566 let base = dir.path();
1567
1568 let legacy = base.join(cas::LEGACY_CAS_DIR);
1570 let hash = cas::store(&legacy, b"icon bytes").unwrap();
1571 let build_dir = base.join("1.0.0_100");
1572 let link = build_dir.join("vfs/gui/x.png");
1573 cas::link_file(&legacy, &hash, &link).unwrap();
1574 write_build_metadata(&build_dir, "1.0.0", 100, &[("gui/x.png", &hash)]);
1575
1576 assert!(migrate_cas_dir_name(base).unwrap());
1577 assert!(base.join(cas::CAS_DIR).exists());
1578 assert!(!base.join(cas::LEGACY_CAS_DIR).exists());
1579 assert!(std::fs::symlink_metadata(&link).unwrap().file_type().is_symlink());
1581 assert_eq!(std::fs::read(&link).unwrap(), b"icon bytes");
1582 assert!(!migrate_cas_dir_name(base).unwrap());
1584 }
1585
1586 #[test]
1587 fn refresh_derived_content_addresses_translations() {
1588 let dir = tempfile::tempdir().unwrap();
1589 let base = dir.path();
1590 let cas_root = base.join(cas::CAS_DIR);
1591 let build_dir = base.join("1.0.0_100");
1592 let mo = build_dir.join("translations/ru/LC_MESSAGES/global.mo");
1594 std::fs::create_dir_all(mo.parent().unwrap()).unwrap();
1595 std::fs::write(&mo, b"catalog bytes").unwrap();
1596
1597 let mut meta = BuildMetadata { version: "1.0.0".into(), build: 100, ..Default::default() };
1598 refresh_build_derived(&build_dir, &cas_root, &mut meta).unwrap();
1599
1600 assert!(std::fs::symlink_metadata(&mo).unwrap().file_type().is_symlink());
1602 assert_eq!(std::fs::read(&mo).unwrap(), b"catalog bytes");
1603 assert!(meta.derived.contains_key("translations/ru/LC_MESSAGES/global.mo"));
1604 }
1605}