Skip to main content

casc_lib/config/
build_info.rs

1//! Parser for the `.build.info` file found at the root of a WoW installation.
2//!
3//! `.build.info` uses a BPSV (Bar-Pipe Separated Values) format where the first
4//! line declares column names with type annotations (e.g. `Name!TYPE:SIZE`) and
5//! subsequent lines contain pipe-delimited data rows - one per installed product.
6
7use std::collections::HashMap;
8
9use crate::error::{CascError, Result};
10
11/// A single entry from the `.build.info` BPSV file.
12///
13/// Each row represents one installed product (e.g. `wow`, `wow_classic`).
14/// The [`build_key`](BuildInfo::build_key) and [`cdn_key`](BuildInfo::cdn_key)
15/// are hex hashes used to locate the build config and CDN config files.
16#[derive(Debug, Clone)]
17pub struct BuildInfo {
18    /// Branch name (e.g. `"eu"`, `"us"`).
19    pub branch: String,
20    /// Whether this entry is the currently active build.
21    pub active: bool,
22    /// Hex hash of the build configuration file.
23    pub build_key: String,
24    /// Hex hash of the CDN configuration file.
25    pub cdn_key: String,
26    /// CDN path prefix (e.g. `"tpr/wow"`).
27    pub cdn_path: String,
28    /// CDN host names for downloading remote data.
29    pub cdn_hosts: Vec<String>,
30    /// Build version string (e.g. `"12.0.1.66192"`).
31    pub version: String,
32    /// Product code (e.g. `"wow"`, `"wow_classic"`).
33    pub product: String,
34    /// Tags string containing locale, region, and speech options.
35    pub tags: String,
36    /// Hex hash of the keyring used for encrypted content.
37    pub keyring: String,
38}
39
40/// Returns all available products and their versions from parsed build info entries.
41pub fn list_products(entries: &[BuildInfo]) -> Vec<(&str, &str)> {
42    entries
43        .iter()
44        .map(|e| (e.product.as_str(), e.version.as_str()))
45        .collect()
46}
47
48/// Parse a `.build.info` BPSV (Bar-Pipe Separated Values) file.
49///
50/// The first line contains column definitions like `Name!TYPE:SIZE|...`.
51/// Subsequent lines are pipe-delimited data rows.
52pub fn parse_build_info(content: &str) -> Result<Vec<BuildInfo>> {
53    let mut lines = content.lines();
54
55    let header = lines
56        .next()
57        .ok_or_else(|| CascError::InvalidFormat("empty .build.info".into()))?;
58
59    // Parse column names from header (strip the `!TYPE:SIZE` suffix from each)
60    let columns: Vec<&str> = header
61        .split('|')
62        .map(|col| col.split('!').next().unwrap_or(col))
63        .collect();
64
65    // Build a name -> index lookup
66    let index: HashMap<&str, usize> = columns
67        .iter()
68        .enumerate()
69        .map(|(i, &name)| (name, i))
70        .collect();
71
72    let get = |row: &[&str], key: &str| -> String {
73        index
74            .get(key)
75            .and_then(|&i| row.get(i))
76            .map(|s| s.to_string())
77            .unwrap_or_default()
78    };
79
80    let mut entries = Vec::new();
81
82    for line in lines {
83        if line.trim().is_empty() {
84            continue;
85        }
86
87        let fields: Vec<&str> = line.split('|').collect();
88
89        let active_str = get(&fields, "Active");
90        let active = active_str == "1";
91
92        let cdn_hosts_raw = get(&fields, "CDN Hosts");
93        let cdn_hosts: Vec<String> = if cdn_hosts_raw.is_empty() {
94            Vec::new()
95        } else {
96            cdn_hosts_raw.split(' ').map(String::from).collect()
97        };
98
99        entries.push(BuildInfo {
100            branch: get(&fields, "Branch"),
101            active,
102            build_key: get(&fields, "Build Key"),
103            cdn_key: get(&fields, "CDN Key"),
104            cdn_path: get(&fields, "CDN Path"),
105            cdn_hosts,
106            version: get(&fields, "Version"),
107            product: get(&fields, "Product"),
108            tags: get(&fields, "Tags"),
109            keyring: get(&fields, "KeyRing"),
110        });
111    }
112
113    Ok(entries)
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119
120    const FIXTURE: &str = "Branch!STRING:0|Active!DEC:1|Build Key!HEX:16|CDN Key!HEX:16|Install Key!HEX:16|IM Size!DEC:4|CDN Path!STRING:0|CDN Hosts!STRING:0|CDN Servers!STRING:0|Tags!STRING:0|Armadillo!STRING:0|Last Activated!STRING:0|Version!STRING:0|KeyRing!HEX:16|Product!STRING:0\neu|1|13e1eb56839dfaf734d7fab21b0c8ea4|36b8057b5cb2175a551325240251f1c0|||tpr/wow|level3.blizzard.com blzddist1-a.akamaihd.net eu.cdn.blizzard.com|blizzard cdn|Windows?enUS?EU?speechoptions=enUS,enGB,deDE,esES,esMX,frFR,itIT,ptBR,ruRU,koKR,zhTW,zhCN|316c4a8ec31d3948a0e3ad5bd6be86f8||12.0.1.66192|3ca57fe7319a297346440e4d2a03a0cd|wow\neu|1|df2221c87fa81a64523f02a0b31d9586|36b8057b5cb2175a551325240251f1c0|||tpr/wow|level3.blizzard.com blzddist1-a.akamaihd.net eu.cdn.blizzard.com|blizzard cdn|Windows?enUS?EU?speechoptions=enUS,enGB,deDE,esES,esMX,frFR,itIT,koKR,ptBR,ruRU,zhCN,zhTW|||||wow_anniversary";
121
122    #[test]
123    fn parse_two_products() {
124        let infos = parse_build_info(FIXTURE).unwrap();
125        assert_eq!(infos.len(), 2);
126    }
127
128    #[test]
129    fn parse_retail_product() {
130        let infos = parse_build_info(FIXTURE).unwrap();
131        let retail = infos.iter().find(|i| i.product == "wow").unwrap();
132        assert_eq!(retail.build_key, "13e1eb56839dfaf734d7fab21b0c8ea4");
133        assert_eq!(retail.cdn_key, "36b8057b5cb2175a551325240251f1c0");
134        assert_eq!(retail.version, "12.0.1.66192");
135        assert!(retail.active);
136    }
137
138    #[test]
139    fn parse_anniversary_product() {
140        let infos = parse_build_info(FIXTURE).unwrap();
141        let anni = infos
142            .iter()
143            .find(|i| i.product == "wow_anniversary")
144            .unwrap();
145        assert_eq!(anni.build_key, "df2221c87fa81a64523f02a0b31d9586");
146        assert_eq!(anni.version, ""); // anniversary has no version in this data
147        assert!(anni.active);
148    }
149
150    #[test]
151    fn parse_inactive_filtered() {
152        let data =
153            "Branch!STRING:0|Active!DEC:1|Build Key!HEX:16|Product!STRING:0\neu|0|abc|test_product";
154        let infos = parse_build_info(data).unwrap();
155        assert!(!infos.iter().all(|i| i.active)); // inactive should still be parsed but marked
156        let inactive = infos.iter().find(|i| i.product == "test_product").unwrap();
157        assert!(!inactive.active);
158    }
159
160    #[test]
161    fn parse_empty_returns_empty() {
162        let data = "Branch!STRING:0|Active!DEC:1|Build Key!HEX:16|Product!STRING:0\n";
163        let infos = parse_build_info(data).unwrap();
164        assert!(infos.is_empty());
165    }
166
167    #[test]
168    fn list_products_returns_all() {
169        let infos = parse_build_info(FIXTURE).unwrap();
170        let products = list_products(&infos);
171        assert_eq!(products.len(), 2);
172        assert_eq!(products[0].0, "wow");
173        assert_eq!(products[0].1, "12.0.1.66192");
174        assert_eq!(products[1].0, "wow_anniversary");
175        assert_eq!(products[1].1, "");
176    }
177
178    #[test]
179    fn list_products_empty_entries() {
180        let products = list_products(&[]);
181        assert!(products.is_empty());
182    }
183}