bpi-rs 0.2.0

Bilibili API client library for Rust
Documentation
use hmac::{Hmac, Mac};
use sha2::Sha256;

use crate::{BpiError, BpiResult};

const KEY_ID: &str = "ec02";
const HMAC_KEY: &str = "XgwSnGZ1p";

type HmacSha256 = Hmac<Sha256>;

pub fn hexsign(key: &str, timestamp: u64) -> BpiResult<String> {
    if key.is_empty() {
        return Err(BpiError::invalid_parameter(
            "key",
            "HMAC key cannot be empty",
        ));
    }

    let message = format!("ts{timestamp}");
    let mut mac = HmacSha256::new_from_slice(key.as_bytes())
        .map_err(|error| BpiError::parse(format!("HMAC key error: {error}")))?;

    mac.update(message.as_bytes());
    Ok(hex::encode(mac.finalize().into_bytes()))
}

pub fn ticket_hexsign(timestamp: u64) -> BpiResult<String> {
    hexsign(HMAC_KEY, timestamp)
}

pub fn ticket_request_params(timestamp: u64, csrf: &str) -> BpiResult<Vec<(String, String)>> {
    Ok(vec![
        ("key_id".to_string(), KEY_ID.to_string()),
        ("hexsign".to_string(), hexsign(HMAC_KEY, timestamp)?),
        ("context[ts]".to_string(), timestamp.to_string()),
        ("csrf".to_string(), csrf.to_string()),
    ])
}

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

    #[test]
    fn hexsign_returns_stable_lowercase_digest() -> Result<(), BpiError> {
        let digest = hexsign("XgwSnGZ1p", 1_234_567_890)?;

        assert_eq!(
            digest,
            "a7da9d971f117aa2b439c4b6cc46c7afbba8ade9f3ca959578af1bcfb37ebd2f"
        );
        assert!(digest.chars().all(|c| c.is_ascii_hexdigit()));
        Ok(())
    }

    #[test]
    fn ticket_hexsign_uses_web_ticket_hmac_key() -> Result<(), BpiError> {
        assert_eq!(
            ticket_hexsign(1_234_567_890)?,
            "a7da9d971f117aa2b439c4b6cc46c7afbba8ade9f3ca959578af1bcfb37ebd2f"
        );
        Ok(())
    }

    #[test]
    fn hexsign_rejects_empty_key() {
        let err = hexsign("", 1_234_567_890).unwrap_err();

        assert!(matches!(
            err,
            BpiError::InvalidParameter { field: "key", .. }
        ));
    }

    #[test]
    fn ticket_request_params_include_required_fields() -> Result<(), BpiError> {
        let params = ticket_request_params(1_234_567_890, "csrf-token")?;

        assert_eq!(
            params,
            vec![
                ("key_id".to_string(), "ec02".to_string()),
                ("hexsign".to_string(), ticket_hexsign(1_234_567_890)?),
                ("context[ts]".to_string(), "1234567890".to_string()),
                ("csrf".to_string(), "csrf-token".to_string()),
            ]
        );
        Ok(())
    }

    #[test]
    fn ticket_request_params_allows_empty_csrf_for_guest_ticket() -> Result<(), BpiError> {
        let params = ticket_request_params(1_234_567_890, "")?;

        assert_eq!(params[3], ("csrf".to_string(), "".to_string()));
        Ok(())
    }
}