Skip to main content

bbdown_core/
login.rs

1use crate::{BiliClient, Credentials, Error, Result};
2use md5::Digest;
3use reqwest::header::{HeaderMap, SET_COOKIE};
4use serde::{Deserialize, Serialize};
5use std::{
6    fmt,
7    time::{SystemTime, UNIX_EPOCH},
8};
9
10const WEB_QR_WAITING_SCAN: i64 = 86_101;
11const WEB_QR_WAITING_CONFIRM: i64 = 86_090;
12const WEB_QR_EXPIRED: i64 = 86_038;
13const TV_QR_WAITING_SCAN: i64 = 86_039;
14const TV_QR_WAITING_CONFIRM: i64 = 86_090;
15const TV_QR_EXPIRED: i64 = 86_038;
16
17#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
18#[serde(rename_all = "snake_case")]
19pub enum QrLoginKind {
20    Web,
21    Tv,
22}
23
24#[derive(Clone, Eq, PartialEq)]
25pub struct QrLoginTicket {
26    pub kind: QrLoginKind,
27    pub url: String,
28    pub key: String,
29    tv_context: Option<TvLoginContext>,
30}
31
32impl fmt::Debug for QrLoginTicket {
33    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
34        formatter
35            .debug_struct("QrLoginTicket")
36            .field("kind", &self.kind)
37            .field("has_url", &!self.url.is_empty())
38            .field("has_key", &!self.key.is_empty())
39            .field("has_tv_context", &self.tv_context.is_some())
40            .finish()
41    }
42}
43
44#[derive(Clone, Debug, Eq, PartialEq)]
45pub enum QrLoginState {
46    WaitingForScan,
47    WaitingForConfirm,
48    Expired,
49    Succeeded { credentials: Credentials },
50}
51
52impl BiliClient {
53    pub async fn create_web_qr_login(&self) -> Result<QrLoginTicket> {
54        let mut url = Self::endpoint_url(
55            &self.config.endpoints.passport_base,
56            "/x/passport-login/web/qrcode/generate",
57        )?;
58        url.query_pairs_mut()
59            .append_pair("source", "main-fe-header");
60        let response = self
61            .http
62            .get(url)
63            .headers(self.anonymous_headers()?)
64            .timeout(self.config.request_timeout)
65            .send()
66            .await
67            .map_err(BiliClient::http_error_without_url)?
68            .error_for_status()
69            .map_err(BiliClient::http_error_without_url)?
70            .json::<ApiData<WebQrGenerateData>>()
71            .await
72            .map_err(BiliClient::http_error_without_url)?;
73        let data = response.into_data()?;
74        let key = data
75            .qrcode_key
76            .or_else(|| qrcode_key_from_url(&data.url))
77            .ok_or(Error::MissingField("qrcode_key"))?;
78        Ok(QrLoginTicket {
79            kind: QrLoginKind::Web,
80            url: data.url,
81            key,
82            tv_context: None,
83        })
84    }
85
86    pub async fn poll_web_qr_login(&self, qrcode_key: &str) -> Result<QrLoginState> {
87        let mut url = Self::endpoint_url(
88            &self.config.endpoints.passport_base,
89            "/x/passport-login/web/qrcode/poll",
90        )?;
91        url.query_pairs_mut()
92            .append_pair("qrcode_key", qrcode_key)
93            .append_pair("source", "main-fe-header");
94        let response = self
95            .http
96            .get(url)
97            .headers(self.anonymous_headers()?)
98            .timeout(self.config.request_timeout)
99            .send()
100            .await
101            .map_err(BiliClient::http_error_without_url)?
102            .error_for_status()
103            .map_err(BiliClient::http_error_without_url)?;
104        let header_cookie = cookie_from_set_cookie_headers(response.headers());
105        let response = response
106            .json::<ApiData<WebQrPollData>>()
107            .await
108            .map_err(BiliClient::http_error_without_url)?;
109        let data = response.into_data()?;
110        match data.code {
111            WEB_QR_WAITING_SCAN => Ok(QrLoginState::WaitingForScan),
112            WEB_QR_WAITING_CONFIRM => Ok(QrLoginState::WaitingForConfirm),
113            WEB_QR_EXPIRED => Ok(QrLoginState::Expired),
114            0 => {
115                let cookie = if let Some(cookie) = header_cookie {
116                    cookie
117                } else {
118                    let url = data.url.ok_or(Error::MissingField("url"))?;
119                    cookie_from_success_url(&url)?
120                };
121                Ok(QrLoginState::Succeeded {
122                    credentials: Credentials {
123                        cookie: Some(cookie),
124                        access_key: None,
125                        tv_access_key: None,
126                    },
127                })
128            }
129            code => Err(Error::Api {
130                code,
131                message: data.message.unwrap_or_default(),
132            }),
133        }
134    }
135
136    pub async fn create_tv_qr_login(&self) -> Result<QrLoginTicket> {
137        let url = Self::endpoint_url(
138            &self.config.endpoints.tv_passport_base,
139            "/x/passport-tv-login/qrcode/auth_code",
140        )?;
141        let timestamp = current_timestamp_seconds();
142        let context = TvLoginContext::new(timestamp);
143        let params = context.params("", timestamp);
144        let response = self
145            .http
146            .post(url)
147            .headers(self.anonymous_headers()?)
148            .timeout(self.config.request_timeout)
149            .form(&params)
150            .send()
151            .await
152            .map_err(BiliClient::http_error_without_url)?
153            .error_for_status()
154            .map_err(BiliClient::http_error_without_url)?
155            .json::<ApiData<TvQrGenerateData>>()
156            .await
157            .map_err(BiliClient::http_error_without_url)?;
158        let data = response.into_data()?;
159        Ok(QrLoginTicket {
160            kind: QrLoginKind::Tv,
161            url: data.url,
162            key: data.auth_code,
163            tv_context: Some(context),
164        })
165    }
166
167    pub async fn poll_tv_qr_login(&self, ticket: &QrLoginTicket) -> Result<QrLoginState> {
168        if ticket.kind != QrLoginKind::Tv {
169            return Err(Error::InvalidInput(
170                "poll_tv_qr_login requires a TV QR login ticket".to_owned(),
171            ));
172        }
173        let context = ticket
174            .tv_context
175            .as_ref()
176            .ok_or(Error::MissingField("tv login context"))?;
177        let url = Self::endpoint_url(
178            &self.config.endpoints.tv_passport_poll_base,
179            "/x/passport-tv-login/qrcode/poll",
180        )?;
181        let params = context.params(&ticket.key, current_timestamp_seconds());
182        let response = self
183            .http
184            .post(url)
185            .headers(self.anonymous_headers()?)
186            .timeout(self.config.request_timeout)
187            .form(&params)
188            .send()
189            .await
190            .map_err(BiliClient::http_error_without_url)?
191            .error_for_status()
192            .map_err(BiliClient::http_error_without_url)?
193            .json::<ApiData<TvQrPollData>>()
194            .await
195            .map_err(BiliClient::http_error_without_url)?;
196        match response.code {
197            TV_QR_WAITING_SCAN => Ok(QrLoginState::WaitingForScan),
198            TV_QR_WAITING_CONFIRM => Ok(QrLoginState::WaitingForConfirm),
199            TV_QR_EXPIRED => Ok(QrLoginState::Expired),
200            0 => {
201                let data = response.data.ok_or(Error::MissingField("data"))?;
202                Ok(QrLoginState::Succeeded {
203                    credentials: Credentials {
204                        cookie: None,
205                        access_key: None,
206                        tv_access_key: Some(data.access_token),
207                    },
208                })
209            }
210            code => Err(Error::Api {
211                code,
212                message: response.message,
213            }),
214        }
215    }
216}
217
218#[derive(Debug, Deserialize)]
219struct ApiData<T> {
220    code: i64,
221    #[serde(default)]
222    message: String,
223    data: Option<T>,
224}
225
226impl<T> ApiData<T> {
227    fn into_data(self) -> Result<T> {
228        if self.code != 0 {
229            return Err(Error::Api {
230                code: self.code,
231                message: self.message,
232            });
233        }
234        self.data.ok_or(Error::MissingField("data"))
235    }
236}
237
238#[derive(Debug, Deserialize)]
239struct WebQrGenerateData {
240    url: String,
241    qrcode_key: Option<String>,
242}
243
244#[derive(Debug, Deserialize)]
245struct WebQrPollData {
246    code: i64,
247    message: Option<String>,
248    url: Option<String>,
249}
250
251#[derive(Debug, Deserialize)]
252struct TvQrGenerateData {
253    url: String,
254    auth_code: String,
255}
256
257#[derive(Debug, Deserialize)]
258struct TvQrPollData {
259    access_token: String,
260}
261
262#[derive(Clone, Eq, PartialEq)]
263struct TvLoginContext {
264    device_id: String,
265    buvid: String,
266    fingerprint: String,
267}
268
269impl TvLoginContext {
270    fn new(timestamp: u64) -> Self {
271        let device_id = device_token("device", timestamp, 20);
272        let buvid = device_token("buvid", timestamp, 37);
273        let fingerprint = format!(
274            "{}{}",
275            timestamp,
276            device_token("fingerprint", timestamp, 45)
277        );
278        Self {
279            device_id,
280            buvid,
281            fingerprint,
282        }
283    }
284
285    fn params(&self, auth_code: &str, timestamp: u64) -> Vec<(&'static str, String)> {
286        let mut params = vec![
287            ("appkey", "4409e2ce8ffd12b8".to_owned()),
288            ("auth_code", auth_code.to_owned()),
289            ("bili_local_id", self.device_id.clone()),
290            ("build", "102801".to_owned()),
291            ("buvid", self.buvid.clone()),
292            ("channel", "master".to_owned()),
293            ("device", "OnePlus".to_owned()),
294            ("device_id", self.device_id.clone()),
295            ("device_name", "OnePlus7TPro".to_owned()),
296            ("device_platform", "Android10OnePlusHD1910".to_owned()),
297            ("fingerprint", self.fingerprint.clone()),
298            ("guid", self.buvid.clone()),
299            ("local_fingerprint", self.fingerprint.clone()),
300            ("local_id", self.buvid.clone()),
301            ("mobi_app", "android_tv_yst".to_owned()),
302            ("networkstate", "wifi".to_owned()),
303            ("platform", "android".to_owned()),
304            ("sys_ver", "29".to_owned()),
305            ("ts", timestamp.to_string()),
306        ];
307        let sign = crate::client::sign_ordered_params(&params, "59b43e04ad6965f34319062b478f83dd");
308        params.push(("sign", sign));
309        params
310    }
311}
312
313fn qrcode_key_from_url(raw: &str) -> Option<String> {
314    url::Url::parse(raw)
315        .ok()?
316        .query_pairs()
317        .find_map(|(key, value)| {
318            (key == "qrcode_key" && !value.is_empty()).then(|| value.into_owned())
319        })
320}
321
322fn cookie_from_success_url(raw: &str) -> Result<String> {
323    let url = url::Url::parse(raw)?;
324    let query = url.query().ok_or(Error::MissingField("url query"))?;
325    if query.is_empty() {
326        return Err(Error::MissingField("url query"));
327    }
328    Ok(query.replace('&', ";").replace(',', "%2C"))
329}
330
331fn cookie_from_set_cookie_headers(headers: &HeaderMap) -> Option<String> {
332    let pairs = headers
333        .get_all(SET_COOKIE)
334        .iter()
335        .filter_map(|value| value.to_str().ok())
336        .filter_map(cookie_pair_from_set_cookie)
337        .collect::<Vec<_>>();
338    (!pairs.is_empty()).then(|| pairs.join(";"))
339}
340
341fn cookie_pair_from_set_cookie(raw: &str) -> Option<String> {
342    let pair = raw.split(';').next()?.trim();
343    (!pair.is_empty()).then(|| pair.replace(',', "%2C"))
344}
345
346#[cfg(test)]
347fn tv_login_params(auth_code: &str, timestamp: u64) -> Vec<(&'static str, String)> {
348    TvLoginContext::new(timestamp).params(auth_code, timestamp)
349}
350
351fn device_token(label: &str, timestamp: u64, len: usize) -> String {
352    let digest = md5::Md5::digest(format!("{label}:{timestamp}:bbdown-rust").as_bytes());
353    let mut token = format!("{label}{digest:x}");
354    token.retain(|character| character.is_ascii_alphanumeric());
355    token.truncate(len);
356    while token.len() < len {
357        token.push('0');
358    }
359    token
360}
361
362fn current_timestamp_seconds() -> u64 {
363    SystemTime::now()
364        .duration_since(UNIX_EPOCH)
365        .map_or(0, |duration| duration.as_secs())
366}
367
368#[cfg(test)]
369mod tests {
370    use super::{
371        QrLoginState, QrLoginTicket, TvLoginContext, cookie_from_set_cookie_headers,
372        cookie_from_success_url, qrcode_key_from_url, tv_login_params,
373    };
374    use crate::{BiliClient, ClientConfig, Credentials, EndpointConfig, RestrictedAreaConfig};
375    use httpmock::MockServer;
376    use httpmock::prelude::*;
377    use reqwest::header::{HeaderMap, HeaderValue, SET_COOKIE};
378
379    #[test]
380    fn extracts_qrcode_key_from_login_url() {
381        assert_eq!(
382            qrcode_key_from_url("https://passport.example/scan?qrcode_key=abc123").as_deref(),
383            Some("abc123")
384        );
385    }
386
387    #[test]
388    fn converts_web_success_url_query_to_cookie() -> anyhow::Result<()> {
389        assert_eq!(
390            cookie_from_success_url(
391                "https://www.bilibili.com/?SESSDATA=abc%2Cdef&bili_jct=csrf&DedeUserID=1",
392            )?,
393            "SESSDATA=abc%2Cdef;bili_jct=csrf;DedeUserID=1"
394        );
395        Ok(())
396    }
397
398    #[test]
399    fn converts_set_cookie_headers_to_cookie() {
400        let mut headers = HeaderMap::new();
401        headers.append(
402            SET_COOKIE,
403            HeaderValue::from_static("SESSDATA=abc,def; Path=/; Domain=.bilibili.com"),
404        );
405        headers.append(
406            SET_COOKIE,
407            HeaderValue::from_static("bili_jct=csrf; Path=/; Domain=.bilibili.com"),
408        );
409
410        assert_eq!(
411            cookie_from_set_cookie_headers(&headers).as_deref(),
412            Some("SESSDATA=abc%2Cdef;bili_jct=csrf")
413        );
414    }
415
416    #[test]
417    fn qr_login_ticket_debug_is_redacted() {
418        let ticket = QrLoginTicket {
419            kind: super::QrLoginKind::Web,
420            url: "https://passport.example/scan?qrcode_key=SECRET".to_owned(),
421            key: "SECRET".to_owned(),
422            tv_context: None,
423        };
424        let debug = format!("{ticket:?}");
425
426        assert!(debug.contains("kind: Web"));
427        assert!(debug.contains("has_url: true"));
428        assert!(debug.contains("has_key: true"));
429        assert!(!debug.contains("SECRET"));
430        assert!(!debug.contains("qrcode_key"));
431    }
432
433    #[test]
434    fn signs_stable_tv_login_params_after_auth_code() {
435        let params = tv_login_params("AUTH", 1_700_000_000);
436        assert_eq!(
437            params,
438            vec![
439                ("appkey", "4409e2ce8ffd12b8".to_owned()),
440                ("auth_code", "AUTH".to_owned()),
441                ("bili_local_id", "device068a1f84f3b481".to_owned()),
442                ("build", "102801".to_owned()),
443                ("buvid", "buvid9bb49b85083b8fa445ee2eb127052e63".to_owned()),
444                ("channel", "master".to_owned()),
445                ("device", "OnePlus".to_owned()),
446                ("device_id", "device068a1f84f3b481".to_owned()),
447                ("device_name", "OnePlus7TPro".to_owned()),
448                ("device_platform", "Android10OnePlusHD1910".to_owned()),
449                (
450                    "fingerprint",
451                    "1700000000fingerprint2fee77e506dae703f7a1197bd676400600".to_owned()
452                ),
453                ("guid", "buvid9bb49b85083b8fa445ee2eb127052e63".to_owned()),
454                (
455                    "local_fingerprint",
456                    "1700000000fingerprint2fee77e506dae703f7a1197bd676400600".to_owned()
457                ),
458                (
459                    "local_id",
460                    "buvid9bb49b85083b8fa445ee2eb127052e63".to_owned()
461                ),
462                ("mobi_app", "android_tv_yst".to_owned()),
463                ("networkstate", "wifi".to_owned()),
464                ("platform", "android".to_owned()),
465                ("sys_ver", "29".to_owned()),
466                ("ts", "1700000000".to_owned()),
467                ("sign", "fcaa54c903154ca39a4e046b73469f74".to_owned()),
468            ]
469        );
470    }
471
472    #[test]
473    fn tv_login_context_reuses_device_identity_for_poll() {
474        let context = TvLoginContext::new(1_700_000_000);
475        let create_params = context.params("", 1_700_000_000);
476        let poll_params = context.params("AUTH", 1_700_000_050);
477
478        for key in [
479            "bili_local_id",
480            "buvid",
481            "device_id",
482            "fingerprint",
483            "guid",
484            "local_fingerprint",
485            "local_id",
486        ] {
487            assert_eq!(
488                param_value(&create_params, key),
489                param_value(&poll_params, key)
490            );
491        }
492        assert_eq!(param_value(&poll_params, "auth_code"), Some("AUTH"));
493        assert_eq!(param_value(&create_params, "ts"), Some("1700000000"));
494        assert_eq!(param_value(&poll_params, "ts"), Some("1700000050"));
495        assert_ne!(
496            param_value(&create_params, "sign"),
497            param_value(&poll_params, "sign")
498        );
499    }
500
501    #[tokio::test]
502    async fn polls_web_qr_login_states() -> anyhow::Result<()> {
503        let server = MockServer::start();
504        server.mock(|when, then| {
505            when.method(GET)
506                .path("/x/passport-login/web/qrcode/poll")
507                .query_param("qrcode_key", "WAIT")
508                .header_missing("cookie");
509            then.status(200).json_body_obj(&serde_json::json!({
510                "code": 0,
511                "data": {"code": 86101}
512            }));
513        });
514        server.mock(|when, then| {
515            when.method(GET)
516                .path("/x/passport-login/web/qrcode/poll")
517                .query_param("qrcode_key", "CONFIRM")
518                .header_missing("cookie");
519            then.status(200).json_body_obj(&serde_json::json!({
520                "code": 0,
521                "data": {"code": 86090}
522            }));
523        });
524        server.mock(|when, then| {
525            when.method(GET)
526                .path("/x/passport-login/web/qrcode/poll")
527                .query_param("qrcode_key", "DONE")
528                .header_missing("cookie");
529            then.status(200)
530                .header("Set-Cookie", "SESSDATA=sess; Path=/; Domain=.bilibili.com")
531                .json_body_obj(&serde_json::json!({
532                    "code": 0,
533                    "data": {
534                        "code": 0,
535                        "url": "https://passport.biligame.com/crossDomain?source=main_web&go_url=https%3A%2F%2Fpassport.bilibili.com"
536                    }
537                }));
538        });
539        server.mock(|when, then| {
540            when.method(GET)
541                .path("/x/passport-login/web/qrcode/poll")
542                .query_param("qrcode_key", "EXPIRED")
543                .header_missing("cookie");
544            then.status(200).json_body_obj(&serde_json::json!({
545                "code": 0,
546                "data": {
547                    "code": 86038,
548                    "message": "expired"
549                }
550            }));
551        });
552        let client = test_client(&server);
553
554        assert_eq!(
555            client.poll_web_qr_login("WAIT").await?,
556            QrLoginState::WaitingForScan
557        );
558        assert_eq!(
559            client.poll_web_qr_login("CONFIRM").await?,
560            QrLoginState::WaitingForConfirm
561        );
562        assert_eq!(
563            client.poll_web_qr_login("DONE").await?,
564            QrLoginState::Succeeded {
565                credentials: Credentials {
566                    cookie: Some("SESSDATA=sess".to_owned()),
567                    access_key: None,
568                    tv_access_key: None,
569                }
570            }
571        );
572        assert_eq!(
573            client.poll_web_qr_login("EXPIRED").await?,
574            QrLoginState::Expired
575        );
576        Ok(())
577    }
578
579    #[tokio::test]
580    async fn creates_and_polls_tv_qr_login() -> anyhow::Result<()> {
581        let server = MockServer::start();
582        server.mock(|when, then| {
583            when.method(POST)
584                .path("/x/passport-tv-login/qrcode/auth_code")
585                .header_missing("cookie")
586                .form_urlencoded_tuple("appkey", "4409e2ce8ffd12b8")
587                .form_urlencoded_tuple("auth_code", "")
588                .form_urlencoded_tuple("mobi_app", "android_tv_yst")
589                .form_urlencoded_tuple_exists("sign");
590            then.status(200).json_body_obj(&serde_json::json!({
591                "code": 0,
592                "data": {"url": "https://tv.example/scan", "auth_code": "AUTH"}
593            }));
594        });
595        server.mock(|when, then| {
596            when.method(POST)
597                .path("/x/passport-tv-login/qrcode/poll")
598                .header_missing("cookie")
599                .form_urlencoded_tuple("auth_code", "WAIT")
600                .form_urlencoded_tuple_exists("sign");
601            then.status(200).json_body_obj(&serde_json::json!({
602                "code": 86039,
603                "message": "waiting scan"
604            }));
605        });
606        server.mock(|when, then| {
607            when.method(POST)
608                .path("/x/passport-tv-login/qrcode/poll")
609                .header_missing("cookie")
610                .form_urlencoded_tuple("auth_code", "CONFIRM")
611                .form_urlencoded_tuple_exists("sign");
612            then.status(200).json_body_obj(&serde_json::json!({
613                "code": 86090,
614                "message": "waiting confirm"
615            }));
616        });
617        server.mock(|when, then| {
618            when.method(POST)
619                .path("/x/passport-tv-login/qrcode/poll")
620                .header_missing("cookie")
621                .form_urlencoded_tuple("auth_code", "EXPIRED")
622                .form_urlencoded_tuple_exists("sign");
623            then.status(200).json_body_obj(&serde_json::json!({
624                "code": 86038,
625                "message": "expired"
626            }));
627        });
628        server.mock(|when, then| {
629            when.method(POST)
630                .path("/x/passport-tv-login/qrcode/poll")
631                .header_missing("cookie")
632                .form_urlencoded_tuple("auth_code", "AUTH")
633                .form_urlencoded_tuple_exists("sign");
634            then.status(200).json_body_obj(&serde_json::json!({
635                "code": 0,
636                "data": {"access_token": "ACCESS"}
637            }));
638        });
639        let client = test_client(&server);
640        let ticket = client.create_tv_qr_login().await?;
641
642        assert_eq!(ticket.key, "AUTH");
643        let mut wait_ticket = ticket.clone();
644        wait_ticket.key = "WAIT".to_owned();
645        assert_eq!(
646            client.poll_tv_qr_login(&wait_ticket).await?,
647            QrLoginState::WaitingForScan
648        );
649        let mut confirm_ticket = ticket.clone();
650        confirm_ticket.key = "CONFIRM".to_owned();
651        assert_eq!(
652            client.poll_tv_qr_login(&confirm_ticket).await?,
653            QrLoginState::WaitingForConfirm
654        );
655        let mut expired_ticket = ticket.clone();
656        expired_ticket.key = "EXPIRED".to_owned();
657        assert_eq!(
658            client.poll_tv_qr_login(&expired_ticket).await?,
659            QrLoginState::Expired
660        );
661        assert_eq!(
662            client.poll_tv_qr_login(&ticket).await?,
663            QrLoginState::Succeeded {
664                credentials: Credentials {
665                    cookie: None,
666                    access_key: None,
667                    tv_access_key: Some("ACCESS".to_owned()),
668                }
669            }
670        );
671        Ok(())
672    }
673
674    fn test_client(server: &MockServer) -> BiliClient {
675        BiliClient::new(ClientConfig {
676            endpoints: EndpointConfig {
677                api_base: server.base_url(),
678                pgc_base: server.base_url(),
679                intl_base: server.base_url(),
680                comment_base: server.base_url(),
681                passport_base: server.base_url(),
682                tv_passport_base: server.base_url(),
683                tv_passport_poll_base: server.base_url(),
684            },
685            credentials: Credentials {
686                cookie: Some("SESSDATA=old".to_owned()),
687                access_key: None,
688                tv_access_key: None,
689            },
690            restricted_area: RestrictedAreaConfig::default(),
691            user_agent: "test".to_owned(),
692            request_timeout: std::time::Duration::from_secs(30),
693        })
694    }
695
696    fn param_value<'a>(params: &'a [(&str, String)], key: &str) -> Option<&'a str> {
697        params
698            .iter()
699            .find_map(|(candidate, value)| (*candidate == key).then_some(value.as_str()))
700    }
701}