Skip to main content

bpi_rs/session/
account.rs

1use std::collections::HashMap;
2use std::fmt;
3
4use serde::Deserialize;
5
6use crate::{BpiError, BpiResult};
7
8use super::cookie::{CookiePair, parse_cookie_header};
9
10#[derive(Clone, Default, Deserialize)]
11pub struct Account {
12    pub dede_user_id: String,
13    pub sessdata: String,
14    pub bili_jct: String,
15    pub buvid3: String,
16}
17
18#[cfg(any(test, debug_assertions))]
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum TestAccountProfile {
21    Vip,
22    Normal,
23}
24
25#[cfg(any(test, debug_assertions))]
26impl TestAccountProfile {
27    fn section(self) -> &'static str {
28        match self {
29            Self::Vip => "vip",
30            Self::Normal => "normal",
31        }
32    }
33
34    fn suffix(self) -> &'static str {
35        match self {
36            Self::Vip => "_vip",
37            Self::Normal => "_normal",
38        }
39    }
40}
41
42impl Account {
43    pub fn new(dede_user_id: String, sessdata: String, bili_jct: String, buvid3: String) -> Self {
44        Self {
45            dede_user_id,
46            sessdata,
47            bili_jct,
48            buvid3,
49        }
50    }
51
52    pub fn from_cookie_header(cookie_header: &str) -> BpiResult<Self> {
53        let pairs = parse_cookie_header(cookie_header)?;
54        Ok(Self::from_cookie_pairs(&pairs))
55    }
56
57    pub fn from_cookie_pairs(pairs: &[CookiePair]) -> Self {
58        let map: HashMap<&str, &str> = pairs
59            .iter()
60            .map(|(key, value)| (key.as_str(), value.as_str()))
61            .collect();
62
63        Self {
64            dede_user_id: map
65                .get("DedeUserID")
66                .copied()
67                .unwrap_or_default()
68                .to_string(),
69            sessdata: map.get("SESSDATA").copied().unwrap_or_default().to_string(),
70            bili_jct: map.get("bili_jct").copied().unwrap_or_default().to_string(),
71            buvid3: map.get("buvid3").copied().unwrap_or_default().to_string(),
72        }
73    }
74
75    pub fn cookie_pairs(&self) -> Vec<CookiePair> {
76        [
77            ("DedeUserID", self.dede_user_id.as_str()),
78            ("SESSDATA", self.sessdata.as_str()),
79            ("bili_jct", self.bili_jct.as_str()),
80            ("buvid3", self.buvid3.as_str()),
81        ]
82        .into_iter()
83        .filter(|(_, value)| !value.is_empty())
84        .map(|(key, value)| (key.to_string(), value.to_string()))
85        .collect()
86    }
87
88    pub fn csrf(&self) -> BpiResult<&str> {
89        if self.bili_jct.is_empty() {
90            return Err(BpiError::auth("missing csrf token"));
91        }
92
93        Ok(&self.bili_jct)
94    }
95
96    pub fn validate_complete(&self) -> BpiResult<()> {
97        if self.is_complete() {
98            return Ok(());
99        }
100
101        Err(BpiError::invalid_parameter(
102            "account",
103            "account requires DedeUserID, SESSDATA, bili_jct, and buvid3",
104        ))
105    }
106
107    pub fn is_complete(&self) -> bool {
108        !self.dede_user_id.is_empty()
109            && !self.sessdata.is_empty()
110            && !self.bili_jct.is_empty()
111            && !self.buvid3.is_empty()
112    }
113}
114
115impl fmt::Debug for Account {
116    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
117        f.debug_struct("Account")
118            .field("dede_user_id", &redact_if_present(&self.dede_user_id))
119            .field("sessdata", &redact_if_present(&self.sessdata))
120            .field("bili_jct", &redact_if_present(&self.bili_jct))
121            .field("buvid3", &redact_if_present(&self.buvid3))
122            .finish()
123    }
124}
125
126fn redact_if_present(value: &str) -> &'static str {
127    if value.is_empty() {
128        "<empty>"
129    } else {
130        "<redacted>"
131    }
132}
133
134impl Account {
135    #[cfg(any(test, debug_assertions))]
136    pub fn load_test_account() -> BpiResult<Account> {
137        Self::load_test_account_profile(TestAccountProfile::Vip)
138    }
139
140    #[cfg(any(test, debug_assertions))]
141    pub fn load_test_account_profile(profile: TestAccountProfile) -> BpiResult<Account> {
142        Self::load_test_account_profile_from("account.toml", profile)
143    }
144
145    #[cfg(any(test, debug_assertions))]
146    pub fn load_test_account_from(path: impl AsRef<std::path::Path>) -> BpiResult<Account> {
147        Self::load_test_account_profile_from(path, TestAccountProfile::Vip)
148    }
149
150    #[cfg(any(test, debug_assertions))]
151    pub fn load_test_account_profile_from(
152        path: impl AsRef<std::path::Path>,
153        profile: TestAccountProfile,
154    ) -> BpiResult<Account> {
155        use config::{Config, File};
156
157        let path = path.as_ref();
158
159        if !path.exists() {
160            return Err(BpiError::invalid_parameter(
161                "account_path",
162                "account config file does not exist",
163            ));
164        }
165
166        let settings = Config::builder()
167            .add_source(File::from(path.to_path_buf()))
168            .build()
169            .map_err(|err| BpiError::parse(format!("failed to load account config: {err}")))?;
170
171        load_profile_from_settings(&settings, profile)
172    }
173}
174
175#[cfg(any(test, debug_assertions))]
176fn load_profile_from_settings(
177    settings: &config::Config,
178    profile: TestAccountProfile,
179) -> BpiResult<Account> {
180    if let Some(account) = read_account_section(settings, profile.section())? {
181        return Ok(account);
182    }
183
184    if let Some(account) = read_suffixed_account(settings, profile.suffix())? {
185        return Ok(account);
186    }
187
188    if profile == TestAccountProfile::Vip {
189        return settings
190            .clone()
191            .try_deserialize()
192            .map_err(|err| BpiError::parse(format!("failed to parse account config: {err}")));
193    }
194
195    Err(BpiError::invalid_parameter(
196        "account_profile",
197        "account profile does not exist",
198    ))
199}
200
201#[cfg(any(test, debug_assertions))]
202fn read_account_section(
203    settings: &config::Config,
204    section: &'static str,
205) -> BpiResult<Option<Account>> {
206    match settings.get::<Account>(section) {
207        Ok(account) => Ok(Some(account)),
208        Err(config::ConfigError::NotFound(_)) => Ok(None),
209        Err(err) => Err(BpiError::parse(format!(
210            "failed to parse account profile {section}: {err}"
211        ))),
212    }
213}
214
215#[cfg(any(test, debug_assertions))]
216fn read_suffixed_account(
217    settings: &config::Config,
218    suffix: &'static str,
219) -> BpiResult<Option<Account>> {
220    let dede_user_id = read_config_string(settings, &format!("dede_user_id{suffix}"))?;
221    let sessdata = read_config_string(settings, &format!("sessdata{suffix}"))?;
222    let bili_jct = read_config_string(settings, &format!("bili_jct{suffix}"))?;
223    let buvid3 = read_config_string(settings, &format!("buvid3{suffix}"))?;
224
225    if dede_user_id.is_none() && sessdata.is_none() && bili_jct.is_none() && buvid3.is_none() {
226        return Ok(None);
227    }
228
229    let Some(dede_user_id) = dede_user_id else {
230        return Err(incomplete_account_profile());
231    };
232    let Some(sessdata) = sessdata else {
233        return Err(incomplete_account_profile());
234    };
235    let Some(bili_jct) = bili_jct else {
236        return Err(incomplete_account_profile());
237    };
238    let Some(buvid3) = buvid3 else {
239        return Err(incomplete_account_profile());
240    };
241
242    Ok(Some(Account::new(dede_user_id, sessdata, bili_jct, buvid3)))
243}
244
245#[cfg(any(test, debug_assertions))]
246fn read_config_string(settings: &config::Config, key: &str) -> BpiResult<Option<String>> {
247    match settings.get_string(key) {
248        Ok(value) => Ok(Some(value)),
249        Err(config::ConfigError::NotFound(_)) => Ok(None),
250        Err(err) => Err(BpiError::parse(format!(
251            "failed to parse account config key {key}: {err}"
252        ))),
253    }
254}
255
256#[cfg(any(test, debug_assertions))]
257fn incomplete_account_profile() -> BpiError {
258    BpiError::invalid_parameter("account_profile", "account profile is incomplete")
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264    use crate::BpiError;
265    use std::path::PathBuf;
266
267    #[test]
268    fn account_from_cookie_header_extracts_known_fields() -> Result<(), BpiError> {
269        let account = Account::from_cookie_header(
270            "DedeUserID=42; SESSDATA=session; bili_jct=csrf; buvid3=buvid",
271        )?;
272
273        assert_eq!(account.dede_user_id, "42");
274        assert_eq!(account.sessdata, "session");
275        assert_eq!(account.bili_jct, "csrf");
276        assert_eq!(account.buvid3, "buvid");
277        Ok(())
278    }
279
280    #[test]
281    fn csrf_returns_token_when_present() -> Result<(), BpiError> {
282        let account = Account::from_cookie_header("bili_jct=csrf")?;
283
284        assert_eq!(account.csrf()?, "csrf");
285        Ok(())
286    }
287
288    #[test]
289    fn csrf_returns_auth_error_when_missing() {
290        let account = Account::default();
291
292        let err = account.csrf().unwrap_err();
293        assert!(matches!(err, BpiError::Auth { .. }));
294    }
295
296    #[test]
297    fn debug_output_redacts_secret_values() -> Result<(), BpiError> {
298        let account = Account::from_cookie_header(
299            "DedeUserID=42; SESSDATA=session-secret; bili_jct=csrf-secret; buvid3=buvid-secret",
300        )?;
301
302        let debug = format!("{account:?}");
303        assert!(!debug.contains("session-secret"));
304        assert!(!debug.contains("csrf-secret"));
305        assert!(!debug.contains("buvid-secret"));
306        Ok(())
307    }
308
309    #[test]
310    fn complete_account_requires_login_cookie_csrf_and_buvid() -> Result<(), BpiError> {
311        let account = Account::from_cookie_header(
312            "DedeUserID=42; SESSDATA=session; bili_jct=csrf; buvid3=buvid",
313        )?;
314
315        assert!(account.is_complete());
316        Ok(())
317    }
318
319    #[test]
320    fn load_test_account_from_missing_path_does_not_create_file() {
321        let path = unique_test_account_path("missing");
322        assert!(!path.exists());
323
324        let err = Account::load_test_account_from(&path).unwrap_err();
325
326        assert!(matches!(
327            err,
328            BpiError::InvalidParameter {
329                field: "account_path",
330                ..
331            }
332        ));
333        assert!(!path.exists());
334    }
335
336    #[test]
337    fn load_test_account_from_reads_explicit_path() -> Result<(), BpiError> {
338        let path = unique_test_account_path("valid");
339        std::fs::write(
340            &path,
341            r#"
342            bili_jct = "csrf"
343            dede_user_id = "42"
344            sessdata = "session"
345            buvid3 = "buvid"
346            "#,
347        )
348        .map_err(|err| BpiError::parse(err.to_string()))?;
349
350        let account = Account::load_test_account_from(&path)?;
351
352        std::fs::remove_file(&path).map_err(|err| BpiError::parse(err.to_string()))?;
353        assert_eq!(account.dede_user_id, "42");
354        assert_eq!(account.bili_jct, "csrf");
355        Ok(())
356    }
357
358    #[test]
359    fn load_test_account_profile_from_reads_normal_suffix() -> Result<(), BpiError> {
360        let path = unique_test_account_path("normal-suffix");
361        std::fs::write(
362            &path,
363            r#"
364            bili_jct_vip = "csrf-vip"
365            dede_user_id_vip = "42"
366            sessdata_vip = "session-vip"
367            buvid3_vip = "buvid-vip"
368
369            bili_jct_normal = "csrf-normal"
370            dede_user_id_normal = "84"
371            sessdata_normal = "session-normal"
372            buvid3_normal = "buvid-normal"
373            "#,
374        )
375        .map_err(|err| BpiError::parse(err.to_string()))?;
376
377        let account = Account::load_test_account_profile_from(&path, TestAccountProfile::Normal)?;
378
379        std::fs::remove_file(&path).map_err(|err| BpiError::parse(err.to_string()))?;
380        assert_eq!(account.dede_user_id, "84");
381        assert_eq!(account.bili_jct, "csrf-normal");
382        Ok(())
383    }
384
385    #[test]
386    fn load_test_account_profile_from_reads_vip_section() -> Result<(), BpiError> {
387        let path = unique_test_account_path("vip-section");
388        std::fs::write(
389            &path,
390            r#"
391            [vip]
392            bili_jct = "csrf-vip"
393            dede_user_id = "42"
394            sessdata = "session-vip"
395            buvid3 = "buvid-vip"
396            "#,
397        )
398        .map_err(|err| BpiError::parse(err.to_string()))?;
399
400        let account = Account::load_test_account_profile_from(&path, TestAccountProfile::Vip)?;
401
402        std::fs::remove_file(&path).map_err(|err| BpiError::parse(err.to_string()))?;
403        assert_eq!(account.dede_user_id, "42");
404        assert_eq!(account.bili_jct, "csrf-vip");
405        Ok(())
406    }
407
408    #[test]
409    fn load_test_account_profile_from_ignores_arbitrary_profile_section() -> Result<(), BpiError> {
410        let path = unique_test_account_path("arbitrary-section");
411        std::fs::write(
412            &path,
413            r#"
414            [account_normal]
415            bili_jct = "csrf-normal"
416            dede_user_id = "84"
417            sessdata = "session-normal"
418            buvid3 = "buvid-normal"
419            "#,
420        )
421        .map_err(|err| BpiError::parse(err.to_string()))?;
422
423        let err =
424            Account::load_test_account_profile_from(&path, TestAccountProfile::Normal).unwrap_err();
425
426        std::fs::remove_file(&path).map_err(|err| BpiError::parse(err.to_string()))?;
427        assert!(matches!(
428            err,
429            BpiError::InvalidParameter {
430                field: "account_profile",
431                ..
432            }
433        ));
434        Ok(())
435    }
436
437    fn unique_test_account_path(label: &str) -> PathBuf {
438        let nanos = std::time::SystemTime::now()
439            .duration_since(std::time::UNIX_EPOCH)
440            .expect("system clock should be after unix epoch")
441            .as_nanos();
442
443        std::env::temp_dir().join(format!(
444            "bpi-rs-{label}-account-{}-{nanos}.toml",
445            std::process::id(),
446        ))
447    }
448}