1use std::collections::BTreeMap;
8use std::path::Path;
9
10use serde::Deserialize;
11use serde::Serialize;
12
13#[derive(Debug, Default, Serialize, Deserialize)]
17pub struct BuildsIndex {
18 #[serde(default)]
19 pub builds: Vec<BuildEntry>,
20}
21
22#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct BuildEntry {
25 pub version: String,
26 pub build: u32,
27 pub dir: String,
28 pub dumped_at: String,
29}
30
31impl BuildsIndex {
32 pub fn load(path: &Path) -> Self {
34 std::fs::read_to_string(path).ok().and_then(|s| toml::from_str(&s).ok()).unwrap_or_default()
35 }
36
37 pub fn save(&self, path: &Path) -> Result<(), rootcause::Report> {
39 use rootcause::prelude::*;
40 let contents = toml::to_string_pretty(self).attach_with(|| "Failed to serialize builds.toml")?;
41 if let Some(parent) = path.parent() {
42 std::fs::create_dir_all(parent)
43 .attach_with(|| format!("Failed to create directory {}", parent.display()))?;
44 }
45 let tmp = path.with_extension("toml.tmp");
46 std::fs::write(&tmp, &contents).attach_with(|| format!("Failed to write {}", tmp.display()))?;
47 std::fs::rename(&tmp, path)
48 .attach_with(|| format!("Failed to rename {} to {}", tmp.display(), path.display()))?;
49 Ok(())
50 }
51
52 pub fn upsert(&mut self, entry: BuildEntry) {
54 if let Some(existing) = self.builds.iter_mut().find(|e| e.build == entry.build) {
55 *existing = entry;
56 } else {
57 self.builds.push(entry);
58 }
59 self.builds.sort_by_key(|e| e.build);
60 }
61
62 pub fn remove_build(&mut self, build: u32) -> Option<BuildEntry> {
64 let idx = self.builds.iter().position(|e| e.build == build)?;
65 Some(self.builds.remove(idx))
66 }
67
68 pub fn find_by_build(&self, build: u32) -> Option<&BuildEntry> {
70 self.builds.iter().find(|e| e.build == build)
71 }
72
73 pub fn find_by_version(&self, version_query: &str) -> Vec<&BuildEntry> {
76 self.builds.iter().filter(|e| crate::manifest::version_matches(&e.version, version_query)).collect()
77 }
78
79 pub fn resolve_build(&self, target_build: u32, target_version: Option<&str>) -> Option<(&BuildEntry, bool)> {
87 if let Some(entry) = self.find_by_build(target_build) {
89 return Some((entry, true));
90 }
91
92 if let Some(version) = target_version {
94 let candidates = self.find_by_version(version);
95 if !candidates.is_empty() {
96 let closest =
97 candidates.iter().min_by_key(|e| (e.build as i64 - target_build as i64).unsigned_abs()).unwrap();
98 return Some((closest, false));
99 }
100 }
101
102 None
103 }
104}
105
106#[derive(Debug, Default, Serialize, Deserialize)]
110pub struct BuildMetadata {
111 pub version: String,
112 pub build: u32,
113 #[serde(default)]
115 pub files: BTreeMap<String, String>,
116 #[serde(default)]
120 pub derived: BTreeMap<String, String>,
121}
122
123impl BuildMetadata {
124 pub fn load(path: &Path) -> Option<Self> {
126 let contents = std::fs::read_to_string(path).ok()?;
127 toml::from_str(&contents).ok()
128 }
129
130 pub fn save(&self, path: &Path) -> Result<(), rootcause::Report> {
132 use rootcause::prelude::*;
133 let contents = toml::to_string_pretty(self).attach_with(|| "Failed to serialize metadata.toml")?;
134 std::fs::write(path, &contents).attach_with(|| format!("Failed to write {}", path.display()))?;
135 Ok(())
136 }
137
138 pub fn has_file_hashes(&self) -> bool {
140 !self.files.is_empty()
141 }
142
143 pub fn referenced_hashes(&self) -> std::collections::HashSet<String> {
146 self.files.values().chain(self.derived.values()).cloned().collect()
147 }
148}
149
150const NAMED_FILES: usize = 3;
154
155const NAMED_FILES_BUDGET: usize = 160;
164
165pub const MAX_MESSAGE_CHARS: usize = 400;
173
174const FILE_SEPARATOR: &str = ", ";
176
177#[derive(Debug, Clone, PartialEq, Eq)]
186pub struct CorruptObject {
187 pub build: u32,
188 pub version: String,
189 pub hash: String,
192 pub actual: String,
194 pub files: Vec<String>,
198}
199
200impl CorruptObject {
201 pub fn attribute(entry: &BuildEntry, metadata: &BuildMetadata, hash: &str, actual: &str) -> Self {
204 let files: Vec<String> = metadata
205 .files
206 .iter()
207 .chain(metadata.derived.iter())
208 .filter(|(_, referenced)| referenced.as_str() == hash)
209 .map(|(path, _)| path.clone())
210 .collect();
211 Self {
212 build: entry.build,
213 version: entry.version.clone(),
214 hash: hash.to_string(),
215 actual: actual.to_string(),
216 files,
217 }
218 }
219
220 pub fn all_files(&self) -> String {
222 self.files.join(FILE_SEPARATOR)
223 }
224
225 fn named_files(&self) -> (Vec<&str>, usize) {
228 let mut named: Vec<&str> = Vec::new();
229 let mut used = 0;
230 for file in self.files.iter().take(NAMED_FILES) {
231 let separator = if named.is_empty() { 0 } else { FILE_SEPARATOR.len() };
232 if used + separator + file.len() > NAMED_FILES_BUDGET {
233 break;
234 }
235 used += separator + file.len();
236 named.push(file.as_str());
237 }
238 let rest = self.files.len() - named.len();
239 (named, rest)
240 }
241}
242
243impl std::fmt::Display for CorruptObject {
244 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
245 write!(
246 f,
247 "build {} ({}) has a corrupt content object: {} hashed to {}. ",
248 self.build, self.version, self.hash, self.actual
249 )?;
250 match self.named_files() {
251 (named, 0) if named.is_empty() => write!(f, "No file in the build's metadata references it. ")?,
252 (named, rest) if named.is_empty() => {
253 write!(f, "It backs {rest} file(s) whose paths are too long to name here. ")?
254 }
255 (named, 0) => write!(f, "It backs {}. ", named.join(FILE_SEPARATOR))?,
256 (named, rest) => write!(f, "It backs {} and {rest} more. ", named.join(FILE_SEPARATOR))?,
257 }
258 write!(f, "Retrying will not help: the data is corrupt at rest, and the build needs re-publishing.")
259 }
260}
261
262#[cfg(test)]
263mod tests {
264 use super::*;
265
266 #[test]
267 fn builds_index_round_trip() {
268 let dir = tempfile::tempdir().unwrap();
269 let path = dir.path().join("builds.toml");
270
271 let mut index = BuildsIndex::default();
272 index.upsert(BuildEntry {
273 version: "15.1.0".into(),
274 build: 11965230,
275 dir: "15.1.0_11965230".into(),
276 dumped_at: "2025-06-15T10:00:00Z".into(),
277 });
278 index.upsert(BuildEntry {
279 version: "15.2.0".into(),
280 build: 12100000,
281 dir: "15.2.0_12100000".into(),
282 dumped_at: "2025-07-01T14:00:00Z".into(),
283 });
284
285 index.save(&path).unwrap();
286 let loaded = BuildsIndex::load(&path);
287 assert_eq!(loaded.builds.len(), 2);
288 assert_eq!(loaded.builds[0].build, 11965230);
289 }
290
291 #[test]
292 fn resolve_exact_match() {
293 let mut index = BuildsIndex::default();
294 index.upsert(BuildEntry {
295 version: "15.2.0".into(),
296 build: 12100000,
297 dir: "15.2.0_12100000".into(),
298 dumped_at: String::new(),
299 });
300
301 let (entry, exact) = index.resolve_build(12100000, None).unwrap();
302 assert!(exact);
303 assert_eq!(entry.build, 12100000);
304 }
305
306 #[test]
307 fn resolve_version_fallback() {
308 let mut index = BuildsIndex::default();
309 index.upsert(BuildEntry {
310 version: "15.2.0".into(),
311 build: 12100000,
312 dir: "15.2.0_12100000".into(),
313 dumped_at: String::new(),
314 });
315
316 let (entry, exact) = index.resolve_build(12100500, Some("15.2.0")).unwrap();
318 assert!(!exact);
319 assert_eq!(entry.build, 12100000);
320 }
321
322 #[test]
323 fn resolve_no_match() {
324 let index = BuildsIndex::default();
325 assert!(index.resolve_build(99999, Some("99.0.0")).is_none());
326 }
327
328 #[test]
329 fn metadata_round_trip() {
330 let dir = tempfile::tempdir().unwrap();
331 let path = dir.path().join("metadata.toml");
332
333 let mut meta = BuildMetadata {
334 version: "15.2.0".into(),
335 build: 12100000,
336 files: BTreeMap::new(),
337 derived: BTreeMap::new(),
338 };
339 meta.files.insert("gui/test.png".into(), "abcdef1234567890abcd".into());
340
341 meta.save(&path).unwrap();
342 let loaded = BuildMetadata::load(&path).unwrap();
343 assert_eq!(loaded.files.len(), 1);
344 assert!(loaded.has_file_hashes());
345 }
346
347 fn corrupt(files: &[&str]) -> CorruptObject {
348 CorruptObject {
349 build: 12506899,
350 version: "15.4.0".into(),
351 hash: "a24a46f62dc08fd95fc7".into(),
352 actual: "674dcbf6a9204c9fe942".into(),
353 files: files.iter().map(|f| f.to_string()).collect(),
354 }
355 }
356
357 fn path_of(len: usize, index: usize) -> String {
361 let prefix = "res/content/gameplay/common/spaces/";
362 let tail = format!("/{index:05}.dds");
363 let filler = len.saturating_sub(prefix.len() + tail.len());
364 format!("{prefix}{}{tail}", "s".repeat(filler))
365 }
366
367 #[test]
371 fn a_corrupt_object_message_names_the_build_and_caps_the_file_list() {
372 let files: Vec<String> = (0..9).map(|i| path_of(88, i)).collect();
373 let err = corrupt(&files.iter().map(String::as_str).collect::<Vec<_>>());
374
375 let rendered = err.to_string();
376 assert_eq!(
377 rendered,
378 format!(
379 "build 12506899 (15.4.0) has a corrupt content object: a24a46f62dc08fd95fc7 hashed to \
380 674dcbf6a9204c9fe942. It backs {} and 8 more. Retrying will not help: the data is corrupt at \
381 rest, and the build needs re-publishing.",
382 files[0]
383 )
384 );
385 assert!(!rendered.contains("00001.dds"), "a second path of this length does not fit: {rendered}");
386 assert!(rendered.len() <= MAX_MESSAGE_CHARS, "got {}", rendered.len());
387 }
388
389 #[test]
394 fn a_corrupt_object_message_stays_within_its_length_bound() {
395 let mut worst = String::new();
396 for path_len in [28, 49, 71, 88, 101, 250, 4_000] {
397 for count in [1, 2, 3, 4, 9, 4_074] {
398 let files: Vec<String> = (0..count).map(|i| path_of(path_len, i)).collect();
399 let err = CorruptObject {
400 build: u32::MAX,
401 version: "15.6.0-preview-build".into(),
402 hash: "a24a46f62dc08fd95fc7".into(),
403 actual: "674dcbf6a9204c9fe942".into(),
404 files,
405 };
406
407 let rendered = err.to_string();
408 if rendered.len() > worst.len() {
409 worst = rendered;
410 }
411 }
412 }
413
414 assert!(worst.len() <= MAX_MESSAGE_CHARS, "the longest rendered at {}: {worst}", worst.len());
415 }
416
417 #[test]
420 fn a_path_too_long_for_the_budget_is_dropped_whole_and_still_counted() {
421 let long = path_of(4_000, 0);
422 let err = corrupt(&[long.as_str(), "res/b.xml"]);
423
424 let rendered = err.to_string();
425 assert!(rendered.contains("It backs 2 file(s) whose paths are too long to name here."), "{rendered}");
426 assert!(!rendered.contains("res/content/gameplay"), "no partial path may appear: {rendered}");
427 }
428
429 #[test]
431 fn three_or_fewer_files_are_all_named_with_no_more_suffix() {
432 let rendered = corrupt(&["content/GameParams.data", "gui/ribbons.png"]).to_string();
433
434 assert_eq!(
435 rendered,
436 "build 12506899 (15.4.0) has a corrupt content object: a24a46f62dc08fd95fc7 hashed to \
437 674dcbf6a9204c9fe942. It backs content/GameParams.data, gui/ribbons.png. Retrying will not help: \
438 the data is corrupt at rest, and the build needs re-publishing."
439 );
440 assert!(!rendered.contains("more"), "no truncation tail belongs here: {rendered}");
441 }
442
443 #[test]
445 fn the_full_file_list_survives_for_the_log() {
446 let files: Vec<String> = (0..9).map(|i| format!("res/spaces/s{i}/space.settings")).collect();
447 let err = corrupt(&files.iter().map(String::as_str).collect::<Vec<_>>());
448
449 assert_eq!(err.all_files(), files.join(", "));
450 assert!(err.all_files().contains("res/spaces/s8/space.settings"));
451 }
452
453 #[test]
456 fn attribution_names_every_path_backed_by_the_hash() {
457 let entry = BuildEntry {
458 version: "15.4.0".into(),
459 build: 12506899,
460 dir: "15.4.0_12506899".into(),
461 dumped_at: String::new(),
462 };
463 let mut metadata = BuildMetadata { version: "15.4.0".into(), build: 12506899, ..Default::default() };
464 metadata.files.insert("res/a.xml".into(), "a24a46f62dc08fd95fc7".into());
465 metadata.files.insert("res/b.xml".into(), "a24a46f62dc08fd95fc7".into());
466 metadata.files.insert("res/other.xml".into(), "11111111111111111111".into());
467 metadata.derived.insert("GameParams.rkyv".into(), "a24a46f62dc08fd95fc7".into());
468
469 let err = CorruptObject::attribute(&entry, &metadata, "a24a46f62dc08fd95fc7", "674dcbf6a9204c9fe942");
470
471 assert_eq!(err.files, vec!["res/a.xml", "res/b.xml", "GameParams.rkyv"]);
472 assert_eq!(err.build, 12506899);
473 assert_eq!(err.version, "15.4.0");
474 }
475
476 #[test]
478 fn an_unreferenced_hash_still_renders_a_sensible_message() {
479 let rendered = corrupt(&[]).to_string();
480
481 assert!(rendered.contains("No file in the build's metadata references it"), "{rendered}");
482 assert!(!rendered.contains("It backs"), "{rendered}");
483 }
484
485 #[test]
486 fn old_format_metadata_loads() {
487 let dir = tempfile::tempdir().unwrap();
488 let path = dir.path().join("metadata.toml");
489 std::fs::write(&path, "version = \"15.1.0\"\nbuild = 11965230\n").unwrap();
490
491 let loaded = BuildMetadata::load(&path).unwrap();
492 assert_eq!(loaded.version, "15.1.0");
493 assert!(!loaded.has_file_hashes());
494 }
495}