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}