casc-lib 0.2.0

Pure Rust library for reading World of Warcraft CASC archives
Documentation
//! Parser for the `.build.info` file found at the root of a WoW installation.
//!
//! `.build.info` uses a BPSV (Bar-Pipe Separated Values) format where the first
//! line declares column names with type annotations (e.g. `Name!TYPE:SIZE`) and
//! subsequent lines contain pipe-delimited data rows - one per installed product.

use std::collections::HashMap;

use crate::error::{CascError, Result};

/// A single entry from the `.build.info` BPSV file.
///
/// Each row represents one installed product (e.g. `wow`, `wow_classic`).
/// The [`build_key`](BuildInfo::build_key) and [`cdn_key`](BuildInfo::cdn_key)
/// are hex hashes used to locate the build config and CDN config files.
#[derive(Debug, Clone)]
pub struct BuildInfo {
    /// Branch name (e.g. `"eu"`, `"us"`).
    pub branch: String,
    /// Whether this entry is the currently active build.
    pub active: bool,
    /// Hex hash of the build configuration file.
    pub build_key: String,
    /// Hex hash of the CDN configuration file.
    pub cdn_key: String,
    /// CDN path prefix (e.g. `"tpr/wow"`).
    pub cdn_path: String,
    /// CDN host names for downloading remote data.
    pub cdn_hosts: Vec<String>,
    /// Build version string (e.g. `"12.0.1.66192"`).
    pub version: String,
    /// Product code (e.g. `"wow"`, `"wow_classic"`).
    pub product: String,
    /// Tags string containing locale, region, and speech options.
    pub tags: String,
    /// Hex hash of the keyring used for encrypted content.
    pub keyring: String,
}

/// Returns all available products and their versions from parsed build info entries.
pub fn list_products(entries: &[BuildInfo]) -> Vec<(&str, &str)> {
    entries
        .iter()
        .map(|e| (e.product.as_str(), e.version.as_str()))
        .collect()
}

/// Parse a `.build.info` BPSV (Bar-Pipe Separated Values) file.
///
/// The first line contains column definitions like `Name!TYPE:SIZE|...`.
/// Subsequent lines are pipe-delimited data rows.
pub fn parse_build_info(content: &str) -> Result<Vec<BuildInfo>> {
    let mut lines = content.lines();

    let header = lines
        .next()
        .ok_or_else(|| CascError::InvalidFormat("empty .build.info".into()))?;

    // Parse column names from header (strip the `!TYPE:SIZE` suffix from each)
    let columns: Vec<&str> = header
        .split('|')
        .map(|col| col.split('!').next().unwrap_or(col))
        .collect();

    // Build a name -> index lookup
    let index: HashMap<&str, usize> = columns
        .iter()
        .enumerate()
        .map(|(i, &name)| (name, i))
        .collect();

    let get = |row: &[&str], key: &str| -> String {
        index
            .get(key)
            .and_then(|&i| row.get(i))
            .map(|s| s.to_string())
            .unwrap_or_default()
    };

    let mut entries = Vec::new();

    for line in lines {
        if line.trim().is_empty() {
            continue;
        }

        let fields: Vec<&str> = line.split('|').collect();

        let active_str = get(&fields, "Active");
        let active = active_str == "1";

        let cdn_hosts_raw = get(&fields, "CDN Hosts");
        let cdn_hosts: Vec<String> = if cdn_hosts_raw.is_empty() {
            Vec::new()
        } else {
            cdn_hosts_raw.split(' ').map(String::from).collect()
        };

        entries.push(BuildInfo {
            branch: get(&fields, "Branch"),
            active,
            build_key: get(&fields, "Build Key"),
            cdn_key: get(&fields, "CDN Key"),
            cdn_path: get(&fields, "CDN Path"),
            cdn_hosts,
            version: get(&fields, "Version"),
            product: get(&fields, "Product"),
            tags: get(&fields, "Tags"),
            keyring: get(&fields, "KeyRing"),
        });
    }

    Ok(entries)
}

#[cfg(test)]
mod tests {
    use super::*;

    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";

    #[test]
    fn parse_two_products() {
        let infos = parse_build_info(FIXTURE).unwrap();
        assert_eq!(infos.len(), 2);
    }

    #[test]
    fn parse_retail_product() {
        let infos = parse_build_info(FIXTURE).unwrap();
        let retail = infos.iter().find(|i| i.product == "wow").unwrap();
        assert_eq!(retail.build_key, "13e1eb56839dfaf734d7fab21b0c8ea4");
        assert_eq!(retail.cdn_key, "36b8057b5cb2175a551325240251f1c0");
        assert_eq!(retail.version, "12.0.1.66192");
        assert!(retail.active);
    }

    #[test]
    fn parse_anniversary_product() {
        let infos = parse_build_info(FIXTURE).unwrap();
        let anni = infos
            .iter()
            .find(|i| i.product == "wow_anniversary")
            .unwrap();
        assert_eq!(anni.build_key, "df2221c87fa81a64523f02a0b31d9586");
        assert_eq!(anni.version, ""); // anniversary has no version in this data
        assert!(anni.active);
    }

    #[test]
    fn parse_inactive_filtered() {
        let data =
            "Branch!STRING:0|Active!DEC:1|Build Key!HEX:16|Product!STRING:0\neu|0|abc|test_product";
        let infos = parse_build_info(data).unwrap();
        assert!(!infos.iter().all(|i| i.active)); // inactive should still be parsed but marked
        let inactive = infos.iter().find(|i| i.product == "test_product").unwrap();
        assert!(!inactive.active);
    }

    #[test]
    fn parse_empty_returns_empty() {
        let data = "Branch!STRING:0|Active!DEC:1|Build Key!HEX:16|Product!STRING:0\n";
        let infos = parse_build_info(data).unwrap();
        assert!(infos.is_empty());
    }

    #[test]
    fn list_products_returns_all() {
        let infos = parse_build_info(FIXTURE).unwrap();
        let products = list_products(&infos);
        assert_eq!(products.len(), 2);
        assert_eq!(products[0].0, "wow");
        assert_eq!(products[0].1, "12.0.1.66192");
        assert_eq!(products[1].0, "wow_anniversary");
        assert_eq!(products[1].1, "");
    }

    #[test]
    fn list_products_empty_entries() {
        let products = list_products(&[]);
        assert!(products.is_empty());
    }
}