bpi-rs 0.2.1

Bilibili API client library for Rust
Documentation
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};

use serde::{Deserialize, Serialize};

use crate::probe::audit::audit_contracts;
use crate::probe::contract::ProbeResult;
use crate::probe::endpoint_contract::ApiRisk;
use crate::probe::sanitize::{audit_value, sanitize_value};
use crate::{BpiError, BpiResult};

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PromoteSummary {
    pub contract_path: PathBuf,
    pub fixture_path: PathBuf,
    pub case_name: String,
    pub api_code: Option<i32>,
}

pub fn promote_probe_output(input: impl AsRef<Path>) -> BpiResult<PromoteSummary> {
    promote_probe_output_with_contract_root(input, Path::new("tests/contracts"))
}

pub fn promote_probe_output_with_contract_root(
    input: impl AsRef<Path>,
    contract_root: &Path,
) -> BpiResult<PromoteSummary> {
    let input = input.as_ref();
    let target = PromoteTarget::from_probe_path(input, contract_root)?;
    let bytes = fs::read(input)
        .map_err(|err| BpiError::parse(format!("failed to read {}: {err}", input.display())))?;
    let mut result: ProbeResult = serde_json::from_slice(&bytes)
        .map_err(|err| BpiError::parse(format!("failed to parse {}: {err}", input.display())))?;
    let risk = read_contract_risk(&target.contract_path).unwrap_or(ApiRisk::PublicRead);

    sanitize_probe_result(&mut result, risk);
    let body = result.response.body;
    let findings = audit_value(&body, Some(ApiRisk::PublicRead));
    if !findings.is_empty() {
        return Err(BpiError::unsupported_response(format!(
            "promoted fixture still contains sensitive fields: {findings:?}"
        )));
    }

    let api_code = body
        .get("code")
        .or_else(|| body.get("errno"))
        .and_then(serde_json::Value::as_i64)
        .map(|value| value as i32);
    let fixture_name = fixture_name(&target.case_name, api_code);
    let fixture_path = target
        .contract_path
        .parent()
        .unwrap_or_else(|| Path::new(""))
        .join("responses")
        .join(&fixture_name);

    if let Some(parent) = fixture_path.parent() {
        fs::create_dir_all(parent).map_err(|err| {
            BpiError::parse(format!("failed to create {}: {err}", parent.display()))
        })?;
    }
    fs::write(
        &fixture_path,
        format!("{}\n", serde_json::to_string_pretty(&body)?),
    )
    .map_err(|err| BpiError::parse(format!("failed to write {}: {err}", fixture_path.display())))?;

    update_contract_case(
        &target.contract_path,
        &target.case_name,
        result.response.status,
        api_code,
        &format!("responses/{fixture_name}"),
    )?;

    let report = audit_contracts(
        target
            .contract_path
            .parent()
            .unwrap_or_else(|| Path::new("")),
    )?;
    if !report.is_success() {
        return Err(BpiError::unsupported_response(format!(
            "promote contract audit failed: {:?}",
            report.findings
        )));
    }

    Ok(PromoteSummary {
        contract_path: target.contract_path,
        fixture_path,
        case_name: target.case_name,
        api_code,
    })
}

#[derive(Debug, Clone, PartialEq, Eq)]
struct PromoteTarget {
    contract_path: PathBuf,
    case_name: String,
}

impl PromoteTarget {
    fn from_probe_path(path: &Path, contract_root: &Path) -> BpiResult<Self> {
        let file_stem = path
            .file_name()
            .and_then(|value| value.to_str())
            .and_then(|name| name.strip_suffix(".response.json"))
            .ok_or_else(|| {
                BpiError::invalid_parameter(
                    "input",
                    "probe output file name must end with .response.json",
                )
            })?;
        let endpoint_dir = path.parent().ok_or_else(|| {
            BpiError::invalid_parameter("input", "probe output path must include endpoint")
        })?;
        let endpoint = endpoint_dir
            .file_name()
            .and_then(|value| value.to_str())
            .ok_or_else(|| BpiError::invalid_parameter("input", "missing endpoint path"))?;
        let batch_dir = endpoint_dir
            .parent()
            .ok_or_else(|| BpiError::invalid_parameter("input", "missing batch path"))?;
        let batch = batch_dir
            .file_name()
            .and_then(|value| value.to_str())
            .ok_or_else(|| BpiError::invalid_parameter("input", "missing batch path"))?;
        let module_dir = batch_dir
            .parent()
            .ok_or_else(|| BpiError::invalid_parameter("input", "missing module path"))?;
        let module = module_dir
            .file_name()
            .and_then(|value| value.to_str())
            .ok_or_else(|| BpiError::invalid_parameter("input", "missing module path"))?;

        Ok(Self {
            contract_path: contract_root
                .join(module)
                .join(batch)
                .join(endpoint)
                .join("contract.json"),
            case_name: file_stem.to_string(),
        })
    }
}

fn read_contract_risk(path: &Path) -> Option<ApiRisk> {
    let bytes = fs::read(path).ok()?;
    let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
    serde_json::from_value(value.get("risk")?.clone()).ok()
}

fn fixture_name(case_name: &str, api_code: Option<i32>) -> String {
    match api_code {
        Some(0) => format!("{case_name}.success.json"),
        Some(code) => format!("{case_name}.{}.json", error_slug(code)),
        None => format!("{case_name}.response.json"),
    }
}

fn error_slug(code: i32) -> &'static str {
    match BpiError::from_code(code).semantic_error() {
        Some("requires_login") => "requires_login",
        Some("requires_vip") => "requires_vip",
        Some("risk_control") => "risk_control",
        Some("permission_denied") => "permission_denied",
        Some("business_error") => "business_error",
        _ => "error",
    }
}

fn update_contract_case(
    path: &Path,
    case_name: &str,
    http_status: u16,
    api_code: Option<i32>,
    fixture: &str,
) -> BpiResult<()> {
    let bytes = fs::read(path)
        .map_err(|err| BpiError::parse(format!("failed to read {}: {err}", path.display())))?;
    let mut value: serde_json::Value = serde_json::from_slice(&bytes)
        .map_err(|err| BpiError::parse(format!("failed to parse {}: {err}", path.display())))?;
    let cases = value
        .get_mut("cases")
        .and_then(serde_json::Value::as_array_mut)
        .ok_or_else(|| BpiError::unsupported_response("contract missing cases array"))?;
    let case = cases
        .iter_mut()
        .find(|case| case.get("name").and_then(serde_json::Value::as_str) == Some(case_name))
        .ok_or_else(|| {
            BpiError::unsupported_response(format!("contract missing case {case_name}"))
        })?;
    let response = case
        .get_mut("response")
        .and_then(serde_json::Value::as_object_mut)
        .ok_or_else(|| BpiError::unsupported_response("case missing response object"))?;

    response.insert("http_status".to_string(), http_status.into());
    if let Some(api_code) = api_code {
        response.insert("api_code".to_string(), api_code.into());
        if api_code != 0 {
            response.insert("error".to_string(), error_slug(api_code).into());
        }
    }
    response.insert("fixture".to_string(), fixture.into());
    response.insert("fixture_kind".to_string(), "sanitized_probe_body".into());
    if api_code == Some(0) {
        response.remove("error");
    }
    response.insert(
        "observed_at".to_string(),
        chrono::Utc::now().date_naive().to_string().into(),
    );

    fs::write(path, format!("{}\n", serde_json::to_string_pretty(&value)?))
        .map_err(|err| BpiError::parse(format!("failed to write {}: {err}", path.display())))?;
    Ok(())
}

fn sanitize_probe_result(result: &mut ProbeResult, risk: ApiRisk) {
    redact_sensitive_headers(&mut result.request.headers);
    redact_sensitive_headers(&mut result.response.headers);
    if let Some(body) = &mut result.request.body {
        sanitize_value(body, Some(risk));
    }
    sanitize_value(&mut result.response.body, Some(risk));
}

fn redact_sensitive_headers(headers: &mut BTreeMap<String, String>) {
    for (name, value) in headers {
        let normalized = name.to_ascii_lowercase();
        if matches!(
            normalized.as_str(),
            "cookie" | "authorization" | "set-cookie" | "x-csrf-token"
        ) {
            *value = "<redacted>".to_string();
        }
    }
}

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

    #[test]
    fn promote_writes_sanitized_fixture_and_updates_contract() -> Result<(), BpiError> {
        let dir = unique_temp_dir("promote")?;
        let contract_root = dir.join("contracts");
        let contract_dir = contract_root
            .join("login")
            .join("vip-info")
            .join("vip-info");
        fs::create_dir_all(&contract_dir).map_err(|err| BpiError::parse(err.to_string()))?;
        fs::write(
            contract_dir.join("contract.json"),
            r#"{
                "schema_version": 2,
                "name": "login.vip_info",
                "module": "login",
                "batch": "vip-info",
                "endpoint": "vip-info",
                "risk": "authenticated-read",
                "status": "promoted",
                "profiles": ["vip"],
                "request": {
                    "method": "GET",
                    "url": "https://api.bilibili.com/x/vip/web/user/info"
                },
                "sanitize": { "preset": ["account"] },
                "provenance": { "source": "unit_test" },
                "cases": [
                    {
                        "name": "vip",
                        "auth": { "profile": "vip", "requires": ["cookie"] },
                        "response": { "http_status": 200, "api_code": 0 }
                    }
                ]
            }"#,
        )
        .map_err(|err| BpiError::parse(err.to_string()))?;

        let probe_path = dir
            .join("runs")
            .join("login")
            .join("vip-info")
            .join("vip-info")
            .join("vip.response.json");
        fs::create_dir_all(probe_path.parent().unwrap())
            .map_err(|err| BpiError::parse(err.to_string()))?;
        fs::write(
            &probe_path,
            r#"{
                "contract": "login.vip_info.vip",
                "request": {
                    "method": "GET",
                    "url": "https://api.bilibili.com/x/vip/web/user/info",
                    "headers": { "cookie": "<redacted>" },
                    "query": {},
                    "body": null
                },
                "response": {
                    "status": 200,
                    "headers": {},
                    "body": {
                        "code": 0,
                        "message": "0",
                        "ttl": 1,
                        "data": {
                            "mid": 42,
                            "name": "real-user",
                            "csrf": "secret"
                        }
                    }
                }
            }"#,
        )
        .map_err(|err| BpiError::parse(err.to_string()))?;

        let summary = promote_probe_output_with_contract_root(&probe_path, &contract_root)?;
        let fixture = fs::read_to_string(&summary.fixture_path)
            .map_err(|err| BpiError::parse(err.to_string()))?;

        assert!(fixture.contains("1000001"));
        assert!(fixture.contains("sanitized-user"));
        assert!(fixture.contains("<redacted>"));

        let contract = fs::read_to_string(contract_dir.join("contract.json"))
            .map_err(|err| BpiError::parse(err.to_string()))?;
        assert!(contract.contains("responses/vip.success.json"));
        assert!(contract.contains("observed_at"));

        fs::remove_dir_all(dir).map_err(|err| BpiError::parse(err.to_string()))?;
        Ok(())
    }

    fn unique_temp_dir(name: &str) -> BpiResult<PathBuf> {
        let dir =
            std::env::temp_dir().join(format!("bpi-rs-promote-{name}-{}", std::process::id()));
        if dir.exists() {
            fs::remove_dir_all(&dir).map_err(|err| BpiError::parse(err.to_string()))?;
        }
        fs::create_dir_all(&dir).map_err(|err| BpiError::parse(err.to_string()))?;
        Ok(dir)
    }
}