1mod dclogin_scheme;
4use std::collections::BTreeMap;
5use std::sync::LazyLock;
6
7use anyhow::{Context as _, Result, anyhow, bail, ensure};
8pub use dclogin_scheme::LoginOptions;
9use deltachat_contact_tools::{ContactAddress, addr_normalize, may_be_valid_addr};
10use percent_encoding::{NON_ALPHANUMERIC, percent_decode_str, percent_encode};
11use serde::Deserialize;
12
13pub(crate) use self::dclogin_scheme::configure_from_login_qr;
14use crate::config::Config;
15use crate::contact::{Contact, ContactId, Origin};
16use crate::context::Context;
17use crate::events::EventType;
18use crate::key::Fingerprint;
19use crate::net::http::post_empty;
20use crate::net::proxy::{DEFAULT_SOCKS_PORT, ProxyConfig};
21use crate::token;
22use crate::tools::validate_id;
23
24const OPENPGP4FPR_SCHEME: &str = "OPENPGP4FPR:"; const IDELTACHAT_SCHEME: &str = "https://i.delta.chat/#";
26const IDELTACHAT_NOSLASH_SCHEME: &str = "https://i.delta.chat#";
27const DCACCOUNT_SCHEME: &str = "DCACCOUNT:";
28pub(super) const DCLOGIN_SCHEME: &str = "DCLOGIN:";
29const TG_SOCKS_SCHEME: &str = "https://t.me/socks";
30const MAILTO_SCHEME: &str = "mailto:";
31const MATMSG_SCHEME: &str = "MATMSG:";
32const VCARD_SCHEME: &str = "BEGIN:VCARD";
33const SMTP_SCHEME: &str = "SMTP:";
34const HTTPS_SCHEME: &str = "https://";
35const SHADOWSOCKS_SCHEME: &str = "ss://";
36
37pub(crate) const DCBACKUP_SCHEME_PREFIX: &str = "DCBACKUP";
39
40pub(crate) const DCBACKUP_VERSION: i32 = 3;
43
44#[derive(Debug, Clone, PartialEq, Eq)]
46pub enum Qr {
47 AskVerifyContact {
51 contact_id: ContactId,
53
54 fingerprint: Fingerprint,
56
57 invitenumber: String,
59
60 authcode: String,
62 },
63
64 AskVerifyGroup {
66 grpname: String,
68
69 grpid: String,
71
72 contact_id: ContactId,
74
75 fingerprint: Fingerprint,
77
78 invitenumber: String,
80
81 authcode: String,
83 },
84
85 FprOk {
89 contact_id: ContactId,
91 },
92
93 FprMismatch {
95 contact_id: Option<ContactId>,
97 },
98
99 FprWithoutAddr {
101 fingerprint: String,
103 },
104
105 Account {
107 domain: String,
109 },
110
111 Backup2 {
113 node_addr: iroh::NodeAddr,
115
116 auth_token: String,
118 },
119
120 BackupTooNew {},
122
123 Proxy {
133 url: String,
137
138 host: String,
140
141 port: u16,
143 },
144
145 Addr {
150 contact_id: ContactId,
152
153 draft: Option<String>,
155 },
156
157 Url {
161 url: String,
163 },
164
165 Text {
169 text: String,
171 },
172
173 WithdrawVerifyContact {
175 contact_id: ContactId,
177
178 fingerprint: Fingerprint,
180
181 invitenumber: String,
183
184 authcode: String,
186 },
187
188 WithdrawVerifyGroup {
190 grpname: String,
192
193 grpid: String,
195
196 contact_id: ContactId,
198
199 fingerprint: Fingerprint,
201
202 invitenumber: String,
204
205 authcode: String,
207 },
208
209 ReviveVerifyContact {
211 contact_id: ContactId,
213
214 fingerprint: Fingerprint,
216
217 invitenumber: String,
219
220 authcode: String,
222 },
223
224 ReviveVerifyGroup {
226 grpname: String,
228
229 grpid: String,
231
232 contact_id: ContactId,
234
235 fingerprint: Fingerprint,
237
238 invitenumber: String,
240
241 authcode: String,
243 },
244
245 Login {
249 address: String,
251
252 options: LoginOptions,
254 },
255}
256
257fn fix_add_second_device_qr(qr: &str) -> String {
260 qr.replacen(r#","info":{"relay_url":"#, r#","relay_url":"#, 1)
261 .replacen(r#""]}}"#, r#""]}"#, 1)
262}
263
264fn starts_with_ignore_case(string: &str, pattern: &str) -> bool {
265 string.to_lowercase().starts_with(&pattern.to_lowercase())
266}
267
268pub async fn check_qr(context: &Context, qr: &str) -> Result<Qr> {
273 let qr = qr.trim();
274 let qrcode = if starts_with_ignore_case(qr, OPENPGP4FPR_SCHEME) {
275 decode_openpgp(context, qr)
276 .await
277 .context("failed to decode OPENPGP4FPR QR code")?
278 } else if qr.starts_with(IDELTACHAT_SCHEME) {
279 decode_ideltachat(context, IDELTACHAT_SCHEME, qr).await?
280 } else if qr.starts_with(IDELTACHAT_NOSLASH_SCHEME) {
281 decode_ideltachat(context, IDELTACHAT_NOSLASH_SCHEME, qr).await?
282 } else if starts_with_ignore_case(qr, DCACCOUNT_SCHEME) {
283 decode_account(qr)?
284 } else if starts_with_ignore_case(qr, DCLOGIN_SCHEME) {
285 dclogin_scheme::decode_login(qr)?
286 } else if starts_with_ignore_case(qr, TG_SOCKS_SCHEME) {
287 decode_tg_socks_proxy(context, qr)?
288 } else if qr.starts_with(SHADOWSOCKS_SCHEME) {
289 decode_shadowsocks_proxy(qr)?
290 } else if starts_with_ignore_case(qr, DCBACKUP_SCHEME_PREFIX) {
291 let qr_fixed = fix_add_second_device_qr(qr);
292 decode_backup2(&qr_fixed)?
293 } else if qr.starts_with(MAILTO_SCHEME) {
294 decode_mailto(context, qr).await?
295 } else if qr.starts_with(SMTP_SCHEME) {
296 decode_smtp(context, qr).await?
297 } else if qr.starts_with(MATMSG_SCHEME) {
298 decode_matmsg(context, qr).await?
299 } else if qr.starts_with(VCARD_SCHEME) {
300 decode_vcard(context, qr).await?
301 } else if let Ok(url) = url::Url::parse(qr) {
302 match url.scheme() {
303 "socks5" => Qr::Proxy {
304 url: qr.to_string(),
305 host: url.host_str().context("URL has no host")?.to_string(),
306 port: url.port().unwrap_or(DEFAULT_SOCKS_PORT),
307 },
308 "http" | "https" => {
309 let url = if let Some(rest) = qr.strip_prefix("http://") {
313 url::Url::parse(&format!("foobarbaz://{rest}"))?
314 } else if let Some(rest) = qr.strip_prefix("https://") {
315 url::Url::parse(&format!("foobarbaz://{rest}"))?
316 } else {
317 url
319 };
320
321 if url.port().is_none() | (url.path() != "") | url.query().is_some() {
322 Qr::Url {
324 url: qr.to_string(),
325 }
326 } else {
327 Qr::Proxy {
328 url: qr.to_string(),
329 host: url.host_str().context("URL has no host")?.to_string(),
330 port: url
331 .port_or_known_default()
332 .context("HTTP(S) URLs are guaranteed to return Some port")?,
333 }
334 }
335 }
336 _ => Qr::Url {
337 url: qr.to_string(),
338 },
339 }
340 } else {
341 Qr::Text {
342 text: qr.to_string(),
343 }
344 };
345 Ok(qrcode)
346}
347
348pub fn format_backup(qr: &Qr) -> Result<String> {
355 match qr {
356 Qr::Backup2 {
357 node_addr,
358 auth_token,
359 } => {
360 let node_addr = serde_json::to_string(node_addr)?;
361 Ok(format!(
362 "{DCBACKUP_SCHEME_PREFIX}{DCBACKUP_VERSION}:{auth_token}&{node_addr}"
363 ))
364 }
365 _ => Err(anyhow!("Not a backup QR code")),
366 }
367}
368
369async fn decode_openpgp(context: &Context, qr: &str) -> Result<Qr> {
373 let payload = qr
374 .get(OPENPGP4FPR_SCHEME.len()..)
375 .context("Invalid OPENPGP4FPR scheme")?;
376
377 let (fingerprint, fragment) = match payload
380 .split_once('#')
381 .or_else(|| payload.split_once("%23"))
382 {
383 Some(pair) => pair,
384 None => (payload, ""),
385 };
386 let fingerprint: Fingerprint = fingerprint
387 .parse()
388 .context("Failed to parse fingerprint in the QR code")?;
389
390 let param: BTreeMap<&str, &str> = fragment
391 .split('&')
392 .filter_map(|s| {
393 if let [key, value] = s.splitn(2, '=').collect::<Vec<_>>()[..] {
394 Some((key, value))
395 } else {
396 None
397 }
398 })
399 .collect();
400
401 let addr = if let Some(addr) = param.get("a") {
402 Some(normalize_address(addr)?)
403 } else {
404 None
405 };
406
407 let name = if let Some(encoded_name) = param.get("n") {
408 let encoded_name = encoded_name.replace('+', "%20"); match percent_decode_str(&encoded_name).decode_utf8() {
410 Ok(name) => name.to_string(),
411 Err(err) => bail!("Invalid name: {err}"),
412 }
413 } else {
414 "".to_string()
415 };
416
417 let invitenumber = param
418 .get("i")
419 .filter(|&s| validate_id(s))
420 .map(|s| s.to_string());
421 let authcode = param
422 .get("s")
423 .filter(|&s| validate_id(s))
424 .map(|s| s.to_string());
425 let grpid = param
426 .get("x")
427 .filter(|&s| validate_id(s))
428 .map(|s| s.to_string());
429
430 let grpname = if grpid.is_some() {
431 if let Some(encoded_name) = param.get("g") {
432 let encoded_name = encoded_name.replace('+', "%20"); match percent_decode_str(&encoded_name).decode_utf8() {
434 Ok(name) => Some(name.to_string()),
435 Err(err) => bail!("Invalid group name: {err}"),
436 }
437 } else {
438 None
439 }
440 } else {
441 None
442 };
443
444 if let (Some(addr), Some(invitenumber), Some(authcode)) = (&addr, invitenumber, authcode) {
445 let addr = ContactAddress::new(addr)?;
446 let (contact_id, _) = Contact::add_or_lookup_ex(
447 context,
448 &name,
449 &addr,
450 &fingerprint.hex(),
451 Origin::UnhandledSecurejoinQrScan,
452 )
453 .await
454 .with_context(|| format!("failed to add or lookup contact for address {addr:?}"))?;
455
456 if let (Some(grpid), Some(grpname)) = (grpid, grpname) {
457 if context
458 .is_self_addr(&addr)
459 .await
460 .with_context(|| format!("can't check if address {addr:?} is our address"))?
461 {
462 if token::exists(context, token::Namespace::InviteNumber, &invitenumber).await? {
463 Ok(Qr::WithdrawVerifyGroup {
464 grpname,
465 grpid,
466 contact_id,
467 fingerprint,
468 invitenumber,
469 authcode,
470 })
471 } else {
472 Ok(Qr::ReviveVerifyGroup {
473 grpname,
474 grpid,
475 contact_id,
476 fingerprint,
477 invitenumber,
478 authcode,
479 })
480 }
481 } else {
482 Ok(Qr::AskVerifyGroup {
483 grpname,
484 grpid,
485 contact_id,
486 fingerprint,
487 invitenumber,
488 authcode,
489 })
490 }
491 } else if context.is_self_addr(&addr).await? {
492 if token::exists(context, token::Namespace::InviteNumber, &invitenumber).await? {
493 Ok(Qr::WithdrawVerifyContact {
494 contact_id,
495 fingerprint,
496 invitenumber,
497 authcode,
498 })
499 } else {
500 Ok(Qr::ReviveVerifyContact {
501 contact_id,
502 fingerprint,
503 invitenumber,
504 authcode,
505 })
506 }
507 } else {
508 Ok(Qr::AskVerifyContact {
509 contact_id,
510 fingerprint,
511 invitenumber,
512 authcode,
513 })
514 }
515 } else if let Some(addr) = addr {
516 let fingerprint = fingerprint.hex();
517 let (contact_id, _) =
518 Contact::add_or_lookup_ex(context, "", &addr, &fingerprint, Origin::UnhandledQrScan)
519 .await?;
520 let contact = Contact::get_by_id(context, contact_id).await?;
521
522 if contact.public_key(context).await?.is_some() {
523 Ok(Qr::FprOk { contact_id })
524 } else {
525 Ok(Qr::FprMismatch {
526 contact_id: Some(contact_id),
527 })
528 }
529 } else {
530 Ok(Qr::FprWithoutAddr {
531 fingerprint: fingerprint.to_string(),
532 })
533 }
534}
535
536async fn decode_ideltachat(context: &Context, prefix: &str, qr: &str) -> Result<Qr> {
538 let qr = qr.replacen(prefix, OPENPGP4FPR_SCHEME, 1);
539 let qr = qr.replacen('&', "#", 1);
540 decode_openpgp(context, &qr)
541 .await
542 .with_context(|| format!("failed to decode {prefix} QR code"))
543}
544
545fn decode_account(qr: &str) -> Result<Qr> {
547 let payload = qr
548 .get(DCACCOUNT_SCHEME.len()..)
549 .context("Invalid DCACCOUNT payload")?;
550 let url = url::Url::parse(payload).context("Invalid account URL")?;
551 if url.scheme() == "http" || url.scheme() == "https" {
552 Ok(Qr::Account {
553 domain: url
554 .host_str()
555 .context("can't extract account setup domain")?
556 .to_string(),
557 })
558 } else {
559 bail!("Bad scheme for account URL: {:?}.", url.scheme());
560 }
561}
562
563fn decode_tg_socks_proxy(_context: &Context, qr: &str) -> Result<Qr> {
565 let url = url::Url::parse(qr).context("Invalid t.me/socks url")?;
566
567 let mut host: Option<String> = None;
568 let mut port: u16 = DEFAULT_SOCKS_PORT;
569 let mut user: Option<String> = None;
570 let mut pass: Option<String> = None;
571 for (key, value) in url.query_pairs() {
572 if key == "server" {
573 host = Some(value.to_string());
574 } else if key == "port" {
575 port = value.parse().unwrap_or(DEFAULT_SOCKS_PORT);
576 } else if key == "user" {
577 user = Some(value.to_string());
578 } else if key == "pass" {
579 pass = Some(value.to_string());
580 }
581 }
582
583 let Some(host) = host else {
584 bail!("Bad t.me/socks url: {url:?}");
585 };
586
587 let mut url = "socks5://".to_string();
588 if let Some(pass) = pass {
589 url += &percent_encode(user.unwrap_or_default().as_bytes(), NON_ALPHANUMERIC).to_string();
590 url += ":";
591 url += &percent_encode(pass.as_bytes(), NON_ALPHANUMERIC).to_string();
592 url += "@";
593 };
594 url += &host;
595 url += ":";
596 url += &port.to_string();
597
598 Ok(Qr::Proxy { url, host, port })
599}
600
601fn decode_shadowsocks_proxy(qr: &str) -> Result<Qr> {
603 let server_config = shadowsocks::config::ServerConfig::from_url(qr)?;
604 let addr = server_config.addr();
605 let host = addr.host().to_string();
606 let port = addr.port();
607 Ok(Qr::Proxy {
608 url: qr.to_string(),
609 host,
610 port,
611 })
612}
613
614fn decode_backup2(qr: &str) -> Result<Qr> {
616 let version_and_payload = qr
617 .strip_prefix(DCBACKUP_SCHEME_PREFIX)
618 .ok_or_else(|| anyhow!("Invalid DCBACKUP scheme"))?;
619 let (version, payload) = version_and_payload
620 .split_once(':')
621 .context("DCBACKUP scheme separator missing")?;
622 let version: i32 = version.parse().context("Not a valid number")?;
623 if version > DCBACKUP_VERSION {
624 return Ok(Qr::BackupTooNew {});
625 }
626
627 let (auth_token, node_addr) = payload
628 .split_once('&')
629 .context("Backup QR code has no separator")?;
630 let auth_token = auth_token.to_string();
631 let node_addr = serde_json::from_str::<iroh::NodeAddr>(node_addr)
632 .context("Invalid node addr in backup QR code")?;
633
634 Ok(Qr::Backup2 {
635 node_addr,
636 auth_token,
637 })
638}
639
640#[derive(Debug, Deserialize)]
641struct CreateAccountSuccessResponse {
642 email: String,
644
645 password: String,
647}
648#[derive(Debug, Deserialize)]
649struct CreateAccountErrorResponse {
650 reason: String,
652}
653
654pub(crate) async fn set_account_from_qr(context: &Context, qr: &str) -> Result<()> {
658 let url_str = qr
659 .get(DCACCOUNT_SCHEME.len()..)
660 .context("Invalid DCACCOUNT scheme")?;
661
662 if !url_str.starts_with(HTTPS_SCHEME) {
663 bail!("DCACCOUNT QR codes must use HTTPS scheme");
664 }
665
666 let (response_text, response_success) = post_empty(context, url_str).await?;
667 if response_success {
668 let CreateAccountSuccessResponse { password, email } = serde_json::from_str(&response_text)
669 .with_context(|| {
670 format!("Cannot create account, response is malformed:\n{response_text:?}")
671 })?;
672 context
673 .set_config_internal(Config::Addr, Some(&email))
674 .await?;
675 context
676 .set_config_internal(Config::MailPw, Some(&password))
677 .await?;
678
679 Ok(())
680 } else {
681 match serde_json::from_str::<CreateAccountErrorResponse>(&response_text) {
682 Ok(error) => Err(anyhow!(error.reason)),
683 Err(parse_error) => {
684 context.emit_event(EventType::Error(format!(
685 "Cannot create account, server response could not be parsed:\n{parse_error:#}\nraw response:\n{response_text}"
686 )));
687 bail!("Cannot create account, unexpected server response:\n{response_text:?}")
688 }
689 }
690 }
691}
692
693pub async fn set_config_from_qr(context: &Context, qr: &str) -> Result<()> {
695 match check_qr(context, qr).await? {
696 Qr::Account { .. } => set_account_from_qr(context, qr).await?,
697 Qr::Proxy { url, .. } => {
698 let old_proxy_url_value = context
699 .get_config(Config::ProxyUrl)
700 .await?
701 .unwrap_or_default();
702
703 let url = ProxyConfig::from_url(&url)?.to_url();
705
706 let proxy_urls: Vec<&str> = std::iter::once(url.as_str())
707 .chain(
708 old_proxy_url_value
709 .split('\n')
710 .filter(|s| !s.is_empty() && *s != url),
711 )
712 .collect();
713 context
714 .set_config(Config::ProxyUrl, Some(&proxy_urls.join("\n")))
715 .await?;
716 context.set_config_bool(Config::ProxyEnabled, true).await?;
717 }
718 Qr::WithdrawVerifyContact {
719 invitenumber,
720 authcode,
721 ..
722 } => {
723 token::delete(context, "").await?;
724 context
725 .sync_qr_code_token_deletion(invitenumber, authcode)
726 .await?;
727 }
728 Qr::WithdrawVerifyGroup {
729 grpid,
730 invitenumber,
731 authcode,
732 ..
733 } => {
734 token::delete(context, &grpid).await?;
735 context
736 .sync_qr_code_token_deletion(invitenumber, authcode)
737 .await?;
738 }
739 Qr::ReviveVerifyContact {
740 invitenumber,
741 authcode,
742 ..
743 } => {
744 token::save(context, token::Namespace::InviteNumber, None, &invitenumber).await?;
745 token::save(context, token::Namespace::Auth, None, &authcode).await?;
746 context.sync_qr_code_tokens(None).await?;
747 context.scheduler.interrupt_inbox().await;
748 }
749 Qr::ReviveVerifyGroup {
750 invitenumber,
751 authcode,
752 grpid,
753 ..
754 } => {
755 token::save(
756 context,
757 token::Namespace::InviteNumber,
758 Some(&grpid),
759 &invitenumber,
760 )
761 .await?;
762 token::save(context, token::Namespace::Auth, Some(&grpid), &authcode).await?;
763 context.sync_qr_code_tokens(Some(&grpid)).await?;
764 context.scheduler.interrupt_inbox().await;
765 }
766 Qr::Login { address, options } => {
767 configure_from_login_qr(context, &address, options).await?
768 }
769 _ => bail!("QR code does not contain config"),
770 }
771
772 Ok(())
773}
774
775async fn decode_mailto(context: &Context, qr: &str) -> Result<Qr> {
779 let payload = qr
780 .get(MAILTO_SCHEME.len()..)
781 .context("Invalid mailto: scheme")?;
782
783 let (addr, query) = payload.split_once('?').unwrap_or((payload, ""));
784
785 let param: BTreeMap<&str, &str> = query
786 .split('&')
787 .filter_map(|s| {
788 if let [key, value] = s.splitn(2, '=').collect::<Vec<_>>()[..] {
789 Some((key, value))
790 } else {
791 None
792 }
793 })
794 .collect();
795
796 let subject = if let Some(subject) = param.get("subject") {
797 subject.to_string()
798 } else {
799 "".to_string()
800 };
801 let draft = if let Some(body) = param.get("body") {
802 if subject.is_empty() {
803 body.to_string()
804 } else {
805 subject + "\n" + body
806 }
807 } else {
808 subject
809 };
810 let draft = draft.replace('+', "%20"); let draft = match percent_decode_str(&draft).decode_utf8() {
812 Ok(decoded_draft) => decoded_draft.to_string(),
813 Err(_err) => draft,
814 };
815
816 let addr = normalize_address(addr)?;
817 let name = "";
818 Qr::from_address(
819 context,
820 name,
821 &addr,
822 if draft.is_empty() { None } else { Some(draft) },
823 )
824 .await
825}
826
827async fn decode_smtp(context: &Context, qr: &str) -> Result<Qr> {
831 let payload = qr.get(SMTP_SCHEME.len()..).context("Invalid SMTP scheme")?;
832
833 let (addr, _rest) = payload
834 .split_once(':')
835 .context("Invalid SMTP scheme payload")?;
836 let addr = normalize_address(addr)?;
837 let name = "";
838 Qr::from_address(context, name, &addr, None).await
839}
840
841async fn decode_matmsg(context: &Context, qr: &str) -> Result<Qr> {
847 let addr = if let Some(to_index) = qr.find("TO:") {
850 let addr = qr.get(to_index + 3..).unwrap_or_default().trim();
851 if let Some(semi_index) = addr.find(';') {
852 addr.get(..semi_index).unwrap_or_default().trim()
853 } else {
854 addr
855 }
856 } else {
857 bail!("Invalid MATMSG found");
858 };
859
860 let addr = normalize_address(addr)?;
861 let name = "";
862 Qr::from_address(context, name, &addr, None).await
863}
864
865static VCARD_NAME_RE: LazyLock<regex::Regex> =
866 LazyLock::new(|| regex::Regex::new(r"(?m)^N:([^;]*);([^;\n]*)").unwrap());
867static VCARD_EMAIL_RE: LazyLock<regex::Regex> =
868 LazyLock::new(|| regex::Regex::new(r"(?m)^EMAIL([^:\n]*):([^;\n]*)").unwrap());
869
870async fn decode_vcard(context: &Context, qr: &str) -> Result<Qr> {
874 let name = VCARD_NAME_RE
875 .captures(qr)
876 .and_then(|caps| {
877 let last_name = caps.get(1)?.as_str().trim();
878 let first_name = caps.get(2)?.as_str().trim();
879
880 Some(format!("{first_name} {last_name}"))
881 })
882 .unwrap_or_default();
883
884 let addr = if let Some(cap) = VCARD_EMAIL_RE.captures(qr).and_then(|caps| caps.get(2)) {
885 normalize_address(cap.as_str().trim())?
886 } else {
887 bail!("Bad e-mail address");
888 };
889
890 Qr::from_address(context, &name, &addr, None).await
891}
892
893impl Qr {
894 pub async fn from_address(
898 context: &Context,
899 name: &str,
900 addr: &str,
901 draft: Option<String>,
902 ) -> Result<Self> {
903 let addr = ContactAddress::new(addr)?;
904 let (contact_id, _) =
905 Contact::add_or_lookup(context, name, &addr, Origin::UnhandledQrScan).await?;
906 Ok(Qr::Addr { contact_id, draft })
907 }
908}
909
910fn normalize_address(addr: &str) -> Result<String> {
912 let new_addr = percent_decode_str(addr).decode_utf8()?;
914 let new_addr = addr_normalize(&new_addr);
915
916 ensure!(may_be_valid_addr(&new_addr), "Bad e-mail address");
917
918 Ok(new_addr.to_string())
919}
920
921#[cfg(test)]
922mod qr_tests;