1use crate::totp::{self, Params, Stored};
35use base64::Engine;
36use serde::{Deserialize, Serialize};
37use serde_json::Value;
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
41#[serde(rename_all = "snake_case")]
42pub enum Format {
43 OtpauthList,
46 Aegis,
48 TwoFas,
50 AndOtp,
52 Bitwarden,
54 GoogleMigration,
56}
57
58impl Format {
59 pub fn as_str(self) -> &'static str {
61 match self {
62 Format::OtpauthList => "otpauth",
63 Format::Aegis => "aegis",
64 Format::TwoFas => "2fas",
65 Format::AndOtp => "andotp",
66 Format::Bitwarden => "bitwarden",
67 Format::GoogleMigration => "google",
68 }
69 }
70
71 pub fn parse(raw: &str) -> Option<Self> {
79 match raw
80 .trim()
81 .to_ascii_lowercase()
82 .replace(['-', '_'], "")
83 .as_str()
84 {
85 "otpauth" | "uri" | "uris" | "txt" | "text" | "ente" | "keepassxc" | "raivo" => {
86 Some(Format::OtpauthList)
87 }
88 "aegis" => Some(Format::Aegis),
89 "2fas" | "twofas" => Some(Format::TwoFas),
90 "andotp" | "freeotp" | "freeotpplus" => Some(Format::AndOtp),
91 "bitwarden" => Some(Format::Bitwarden),
92 "google" | "googleauthenticator" | "gauth" | "migration" => {
93 Some(Format::GoogleMigration)
94 }
95 _ => None,
96 }
97 }
98
99 pub const EXPORTABLE: [Format; 3] = [Format::OtpauthList, Format::Aegis, Format::TwoFas];
107
108 pub fn is_exportable(self) -> bool {
110 Format::EXPORTABLE.contains(&self)
111 }
112}
113
114#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
121pub struct Imported {
122 pub issuer: Option<String>,
123 pub account: Option<String>,
124 #[serde(flatten)]
125 pub stored: Stored,
126 pub note: Option<String>,
128}
129
130impl Imported {
131 pub fn suggested_provider(&self) -> String {
137 self.issuer
138 .as_deref()
139 .map(str::trim)
140 .filter(|s| !s.is_empty())
141 .or_else(|| {
142 self.account
143 .as_deref()
144 .map(str::trim)
145 .filter(|s| !s.is_empty())
146 })
147 .unwrap_or("Unnamed")
148 .to_string()
149 }
150}
151
152#[derive(Debug, Clone, Serialize, Deserialize)]
154pub struct ParseReport {
155 pub format: Format,
156 pub items: Vec<Imported>,
157 pub skipped: Vec<Skipped>,
162}
163
164#[derive(Debug, Clone, Serialize, Deserialize)]
166pub struct Skipped {
167 pub name: String,
169 pub reason: String,
170}
171
172pub fn detect(text: &str) -> Result<Format, String> {
180 let trimmed = text.trim_start_matches('\u{feff}').trim();
181 if trimmed.is_empty() {
182 return Err("the file is empty".into());
183 }
184
185 if trimmed.len() >= 18 && trimmed[..18].eq_ignore_ascii_case("otpauth-migration:") {
186 return Ok(Format::GoogleMigration);
187 }
188 if trimmed.len() >= 8 && trimmed[..8].eq_ignore_ascii_case("otpauth:") {
189 return Ok(Format::OtpauthList);
190 }
191
192 if let Ok(v) = serde_json::from_str::<Value>(trimmed) {
193 if v.get("db").is_some() && v.get("header").is_some() {
196 let encrypted = v
197 .pointer("/header/slots")
198 .map(|s| !s.is_null())
199 .unwrap_or(false)
200 || v.get("db").map(|d| d.is_string()).unwrap_or(false);
201 if encrypted {
202 return Err(encrypted_msg(
203 "Aegis",
204 "Export again with encryption turned off",
205 ));
206 }
207 return Ok(Format::Aegis);
208 }
209 if v.get("services").is_some() || v.get("servicesEncrypted").is_some() {
211 let encrypted = v
212 .get("servicesEncrypted")
213 .and_then(|s| s.as_str())
214 .map(|s| !s.is_empty())
215 .unwrap_or(false);
216 if encrypted {
217 return Err(encrypted_msg(
218 "2FAS",
219 "Export again without a password set on the backup",
220 ));
221 }
222 return Ok(Format::TwoFas);
223 }
224 if v.get("items").is_some() {
226 if v.get("encrypted").and_then(|e| e.as_bool()) == Some(true) {
227 return Err(encrypted_msg(
228 "Bitwarden",
229 "Export again as an unencrypted .json",
230 ));
231 }
232 return Ok(Format::Bitwarden);
233 }
234 if v.as_array()
236 .map(|a| a.iter().any(|e| e.get("secret").is_some()))
237 .unwrap_or(false)
238 {
239 return Ok(Format::AndOtp);
240 }
241 return Err(
242 "recognised the file as JSON but not as an authenticator export \
243 (expected Aegis, 2FAS, andOTP or Bitwarden)"
244 .into(),
245 );
246 }
247
248 if text.lines().any(|l| {
252 let l = l.trim();
253 l.len() >= 8 && l[..8].eq_ignore_ascii_case("otpauth:")
254 }) {
255 return Ok(Format::OtpauthList);
256 }
257
258 Err(
259 "could not tell which authenticator app wrote this file. Supported: \
260 Ente Auth and any otpauth:// list, Aegis, 2FAS, andOTP, Bitwarden, \
261 Google Authenticator"
262 .into(),
263 )
264}
265
266fn encrypted_msg(app: &str, advice: &str) -> String {
267 format!(
268 "this is an *encrypted* {app} export. UnENVerse will not try to decrypt \
269 another app's vault — {advice}, import it here, then delete the \
270 plaintext file."
271 )
272}
273
274pub fn parse(text: &str) -> Result<ParseReport, String> {
278 let format = detect(text)?;
279 parse_as(text, format)
280}
281
282pub fn parse_as(text: &str, format: Format) -> Result<ParseReport, String> {
284 let text = text.trim_start_matches('\u{feff}');
285 let (items, skipped) = match format {
286 Format::OtpauthList => parse_uri_list(text),
287 Format::Aegis => parse_aegis(text)?,
288 Format::TwoFas => parse_2fas(text)?,
289 Format::AndOtp => parse_andotp(text)?,
290 Format::Bitwarden => parse_bitwarden(text)?,
291 Format::GoogleMigration => parse_google(text)?,
292 };
293 Ok(ParseReport {
294 format,
295 items,
296 skipped,
297 })
298}
299
300fn parse_uri_list(text: &str) -> (Vec<Imported>, Vec<Skipped>) {
308 let mut items = Vec::new();
309 let mut skipped = Vec::new();
310 for (n, raw) in text.lines().enumerate() {
311 let line = raw.trim();
312 if line.is_empty() || line.starts_with('#') || line.starts_with("//") {
313 continue;
314 }
315 match totp::parse_otpauth(line) {
316 Ok(stored) => items.push(Imported {
317 issuer: stored.issuer.clone(),
318 account: stored.account.clone(),
319 stored,
320 note: None,
321 }),
322 Err(e) => skipped.push(Skipped {
323 name: format!("line {}", n + 1),
326 reason: e,
327 }),
328 }
329 }
330 (items, skipped)
331}
332
333fn stored_from(
336 secret: &str,
337 kind: Option<&str>,
338 algo: Option<&str>,
339 digits: Option<u64>,
340 period: Option<u64>,
341 counter: Option<u64>,
342) -> Result<Stored, String> {
343 let params = Params::from_fields(kind, algo, digits, period, counter);
347 let mut stored = totp::parse_seed(secret)?;
350 stored.params = params;
351 Ok(stored)
352}
353
354fn nonempty(v: Option<&str>) -> Option<String> {
355 v.map(str::trim)
356 .filter(|s| !s.is_empty())
357 .map(str::to_string)
358}
359
360fn parse_aegis(text: &str) -> Result<(Vec<Imported>, Vec<Skipped>), String> {
361 let v: Value = serde_json::from_str(text).map_err(|e| format!("not valid JSON: {e}"))?;
362 let entries = v
363 .pointer("/db/entries")
364 .and_then(|e| e.as_array())
365 .ok_or("this Aegis file has no db.entries array")?;
366 let mut items = Vec::new();
367 let mut skipped = Vec::new();
368 for e in entries {
369 let name = nonempty(e.get("name").and_then(|x| x.as_str()));
370 let issuer = nonempty(e.get("issuer").and_then(|x| x.as_str()));
371 let label = issuer
372 .clone()
373 .or_else(|| name.clone())
374 .unwrap_or_else(|| "unnamed".into());
375 let kind = e.get("type").and_then(|x| x.as_str()).unwrap_or("totp");
379 let secret = e
380 .pointer("/info/secret")
381 .and_then(|x| x.as_str())
382 .unwrap_or("");
383 match stored_from(
384 secret,
385 Some(kind),
386 e.pointer("/info/algo").and_then(|x| x.as_str()),
387 e.pointer("/info/digits").and_then(|x| x.as_u64()),
388 e.pointer("/info/period").and_then(|x| x.as_u64()),
389 e.pointer("/info/counter").and_then(|x| x.as_u64()),
390 ) {
391 Ok(stored) => items.push(Imported {
392 issuer,
393 account: name,
394 stored,
395 note: nonempty(e.get("note").and_then(|x| x.as_str())),
396 }),
397 Err(reason) => skipped.push(Skipped {
398 name: label,
399 reason,
400 }),
401 }
402 }
403 Ok((items, skipped))
404}
405
406fn parse_2fas(text: &str) -> Result<(Vec<Imported>, Vec<Skipped>), String> {
407 let v: Value = serde_json::from_str(text).map_err(|e| format!("not valid JSON: {e}"))?;
408 let services = v
409 .get("services")
410 .and_then(|s| s.as_array())
411 .ok_or("this 2FAS file has no services array")?;
412 let mut items = Vec::new();
413 let mut skipped = Vec::new();
414 for s in services {
415 let issuer = nonempty(s.pointer("/otp/issuer").and_then(|x| x.as_str()))
419 .or_else(|| nonempty(s.get("name").and_then(|x| x.as_str())));
420 let account = nonempty(s.pointer("/otp/account").and_then(|x| x.as_str()))
421 .or_else(|| nonempty(s.pointer("/otp/label").and_then(|x| x.as_str())));
422 let label = issuer
423 .clone()
424 .or_else(|| account.clone())
425 .unwrap_or_else(|| "unnamed".into());
426 let kind = s
427 .pointer("/otp/tokenType")
428 .and_then(|x| x.as_str())
429 .unwrap_or("TOTP");
430 match stored_from(
431 s.get("secret").and_then(|x| x.as_str()).unwrap_or(""),
432 Some(kind),
433 s.pointer("/otp/algorithm").and_then(|x| x.as_str()),
434 s.pointer("/otp/digits").and_then(|x| x.as_u64()),
435 s.pointer("/otp/period").and_then(|x| x.as_u64()),
436 s.pointer("/otp/counter").and_then(|x| x.as_u64()),
437 ) {
438 Ok(stored) => items.push(Imported {
439 issuer,
440 account,
441 stored,
442 note: None,
443 }),
444 Err(reason) => skipped.push(Skipped {
445 name: label,
446 reason,
447 }),
448 }
449 }
450 Ok((items, skipped))
451}
452
453fn parse_andotp(text: &str) -> Result<(Vec<Imported>, Vec<Skipped>), String> {
454 let v: Value = serde_json::from_str(text).map_err(|e| format!("not valid JSON: {e}"))?;
455 let arr = v
456 .as_array()
457 .ok_or("an andOTP export is a JSON array of entries")?;
458 let mut items = Vec::new();
459 let mut skipped = Vec::new();
460 for e in arr {
461 let issuer = nonempty(e.get("issuer").and_then(|x| x.as_str()));
462 let account = nonempty(e.get("label").and_then(|x| x.as_str()));
463 let label = issuer
464 .clone()
465 .or_else(|| account.clone())
466 .unwrap_or_else(|| "unnamed".into());
467 let kind = e.get("type").and_then(|x| x.as_str()).unwrap_or("TOTP");
468 match stored_from(
469 e.get("secret").and_then(|x| x.as_str()).unwrap_or(""),
470 Some(kind),
471 e.get("algorithm").and_then(|x| x.as_str()),
472 e.get("digits").and_then(|x| x.as_u64()),
473 e.get("period").and_then(|x| x.as_u64()),
474 e.get("counter").and_then(|x| x.as_u64()),
475 ) {
476 Ok(stored) => items.push(Imported {
477 issuer,
478 account,
479 stored,
480 note: e
481 .get("tags")
482 .and_then(|t| t.as_array())
483 .map(|t| {
484 t.iter()
485 .filter_map(|x| x.as_str())
486 .collect::<Vec<_>>()
487 .join(", ")
488 })
489 .filter(|s| !s.is_empty()),
490 }),
491 Err(reason) => skipped.push(Skipped {
492 name: label,
493 reason,
494 }),
495 }
496 }
497 Ok((items, skipped))
498}
499
500fn parse_bitwarden(text: &str) -> Result<(Vec<Imported>, Vec<Skipped>), String> {
506 let v: Value = serde_json::from_str(text).map_err(|e| format!("not valid JSON: {e}"))?;
507 let items_json = v
508 .get("items")
509 .and_then(|i| i.as_array())
510 .ok_or("this Bitwarden file has no items array")?;
511 let mut items = Vec::new();
512 let mut skipped = Vec::new();
513 for it in items_json {
514 let raw = it
515 .pointer("/login/totp")
516 .and_then(|x| x.as_str())
517 .unwrap_or("");
518 if raw.trim().is_empty() {
519 continue;
522 }
523 let name = nonempty(it.get("name").and_then(|x| x.as_str()));
524 let account = nonempty(it.pointer("/login/username").and_then(|x| x.as_str()));
525 let label = name.clone().unwrap_or_else(|| "unnamed".into());
526 match totp::parse_seed(raw) {
527 Ok(stored) => items.push(Imported {
528 issuer: name.or_else(|| stored.issuer.clone()),
531 account: account.or_else(|| stored.account.clone()),
532 stored,
533 note: None,
534 }),
535 Err(reason) => skipped.push(Skipped {
536 name: label,
537 reason,
538 }),
539 }
540 }
541 Ok((items, skipped))
542}
543
544fn parse_google(text: &str) -> Result<(Vec<Imported>, Vec<Skipped>), String> {
558 let mut items = Vec::new();
559 let mut skipped = Vec::new();
560 let mut any = false;
561 for (n, raw) in text.lines().enumerate() {
562 let line = raw.trim();
563 if line.is_empty() || line.starts_with('#') {
564 continue;
565 }
566 if line.len() < 18 || !line[..18].eq_ignore_ascii_case("otpauth-migration:") {
567 skipped.push(Skipped {
568 name: format!("line {}", n + 1),
569 reason: "not an otpauth-migration:// URI".into(),
570 });
571 continue;
572 }
573 any = true;
574 let data = line
575 .split_once("data=")
576 .map(|(_, d)| d.split('&').next().unwrap_or(d))
577 .ok_or("the migration URI carries no data= parameter")?;
578 let decoded = decode_migration_b64(data)?;
579 let (mut got, mut bad) = decode_migration_payload(&decoded)?;
580 items.append(&mut got);
581 skipped.append(&mut bad);
582 }
583 if !any {
584 return Err("no otpauth-migration:// URI in this file".into());
585 }
586 Ok((items, skipped))
587}
588
589fn decode_migration_b64(data: &str) -> Result<Vec<u8>, String> {
596 let pct = percent_decode(data);
597 let cleaned: String = pct.chars().filter(|c| !c.is_whitespace()).collect();
598 use base64::engine::general_purpose as b64;
599 [
600 b64::STANDARD.decode(&cleaned),
601 b64::STANDARD_NO_PAD.decode(&cleaned),
602 b64::URL_SAFE.decode(&cleaned),
603 b64::URL_SAFE_NO_PAD.decode(&cleaned),
604 ]
605 .into_iter()
606 .flatten()
607 .next()
608 .ok_or_else(|| "the migration payload is not valid base64".to_string())
609}
610
611fn percent_decode(s: &str) -> String {
612 let bytes = s.as_bytes();
613 let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
614 let mut i = 0;
615 while i < bytes.len() {
616 if bytes[i] == b'%' && i + 2 < bytes.len() {
617 if let Some(b) = std::str::from_utf8(&bytes[i + 1..i + 3])
618 .ok()
619 .and_then(|h| u8::from_str_radix(h, 16).ok())
620 {
621 out.push(b);
622 i += 3;
623 continue;
624 }
625 }
626 out.push(bytes[i]);
631 i += 1;
632 }
633 String::from_utf8_lossy(&out).into_owned()
634}
635
636struct Pb<'a> {
638 b: &'a [u8],
639 i: usize,
640}
641
642impl<'a> Pb<'a> {
643 fn new(b: &'a [u8]) -> Self {
644 Pb { b, i: 0 }
645 }
646 fn done(&self) -> bool {
647 self.i >= self.b.len()
648 }
649 fn varint(&mut self) -> Result<u64, String> {
652 let mut out: u64 = 0;
653 let mut shift = 0;
654 loop {
655 if self.i >= self.b.len() {
656 return Err("truncated migration payload".into());
657 }
658 let byte = self.b[self.i];
659 self.i += 1;
660 out |= u64::from(byte & 0x7f) << shift;
661 if byte & 0x80 == 0 {
662 return Ok(out);
663 }
664 shift += 7;
665 if shift > 63 {
666 return Err("malformed varint in migration payload".into());
667 }
668 }
669 }
670 fn key(&mut self) -> Result<(u64, u8), String> {
672 let k = self.varint()?;
673 Ok((k >> 3, (k & 0x07) as u8))
674 }
675 fn bytes(&mut self) -> Result<&'a [u8], String> {
676 let len = self.varint()? as usize;
677 let end = self
678 .i
679 .checked_add(len)
680 .filter(|e| *e <= self.b.len())
681 .ok_or("truncated length-delimited field")?;
682 let out = &self.b[self.i..end];
683 self.i = end;
684 Ok(out)
685 }
686 fn skip(&mut self, wire: u8) -> Result<(), String> {
688 match wire {
689 0 => {
690 self.varint()?;
691 }
692 1 => self.i = self.i.saturating_add(8).min(self.b.len()),
693 2 => {
694 self.bytes()?;
695 }
696 5 => self.i = self.i.saturating_add(4).min(self.b.len()),
697 _ => return Err(format!("unsupported protobuf wire type {wire}")),
698 }
699 Ok(())
700 }
701}
702
703fn decode_migration_payload(bytes: &[u8]) -> Result<(Vec<Imported>, Vec<Skipped>), String> {
704 let mut items = Vec::new();
705 let mut skipped = Vec::new();
706 let mut p = Pb::new(bytes);
707 while !p.done() {
708 let (field, wire) = p.key()?;
709 if field == 1 && wire == 2 {
712 let inner = p.bytes()?;
713 match decode_otp_parameters(inner) {
714 Ok(Some(item)) => items.push(item),
715 Ok(None) => {}
716 Err(s) => skipped.push(s),
717 }
718 } else {
719 p.skip(wire)?;
720 }
721 }
722 Ok((items, skipped))
723}
724
725fn decode_otp_parameters(bytes: &[u8]) -> Result<Option<Imported>, Skipped> {
728 let mut secret_raw: Vec<u8> = Vec::new();
729 let mut name = String::new();
730 let mut issuer = String::new();
731 let mut algo = 1u64; let mut digits_enum = 1u64; let mut kind = 2u64; let mut counter = 0u64;
735
736 let mut p = Pb::new(bytes);
737 let named = |n: &str, i: &str| {
738 if !i.is_empty() {
739 i.to_string()
740 } else if !n.is_empty() {
741 n.to_string()
742 } else {
743 "unnamed".to_string()
744 }
745 };
746 while !p.done() {
747 let (field, wire) = p.key().map_err(|e| Skipped {
748 name: named(&name, &issuer),
749 reason: e,
750 })?;
751 let fail = |e: String| Skipped {
752 name: named(&name, &issuer),
753 reason: e,
754 };
755 match (field, wire) {
756 (1, 2) => secret_raw = p.bytes().map_err(fail)?.to_vec(),
757 (2, 2) => name = String::from_utf8_lossy(p.bytes().map_err(fail)?).into_owned(),
758 (3, 2) => issuer = String::from_utf8_lossy(p.bytes().map_err(fail)?).into_owned(),
759 (4, 0) => algo = p.varint().map_err(fail)?,
760 (5, 0) => digits_enum = p.varint().map_err(fail)?,
761 (6, 0) => kind = p.varint().map_err(fail)?,
762 (7, 0) => counter = p.varint().map_err(fail)?,
766 _ => p.skip(wire).map_err(fail)?,
767 }
768 }
769
770 let label = named(&name, &issuer);
771 if secret_raw.is_empty() {
772 return Err(Skipped {
773 name: label,
774 reason: "the migration entry carries no secret".into(),
775 });
776 }
777
778 let secret = totp::base32_encode(&secret_raw);
780 let params = Params {
781 algorithm: match algo {
782 2 => totp::Algorithm::Sha256,
783 3 => totp::Algorithm::Sha512,
784 _ => totp::Algorithm::Sha1,
789 },
790 digits: if digits_enum == 2 { 8 } else { 6 },
791 period: totp::STEP_SECS,
794 kind: if kind == 1 {
797 totp::Kind::Hotp
798 } else {
799 totp::Kind::Totp
800 },
801 counter: if kind == 1 { counter } else { 0 },
802 };
803 let stored = Stored {
804 secret,
805 params,
806 issuer: (!issuer.is_empty()).then(|| issuer.clone()),
807 account: (!name.is_empty()).then(|| name.clone()),
808 };
809 Ok(Some(Imported {
810 issuer: stored.issuer.clone(),
811 account: stored.account.clone(),
812 stored,
813 note: None,
814 }))
815}
816
817pub fn build(items: &[Imported], format: Format) -> Result<String, String> {
825 if !format.is_exportable() {
826 return Err(format!(
827 "'{}' can be imported but not written. Export with {} instead.",
828 format.as_str(),
829 Format::EXPORTABLE
830 .iter()
831 .map(|f| f.as_str())
832 .collect::<Vec<_>>()
833 .join(", ")
834 ));
835 }
836 Ok(match format {
837 Format::OtpauthList => build_uri_list(items),
838 Format::Aegis => build_aegis(items),
839 Format::TwoFas => build_2fas(items),
840 _ => unreachable!("is_exportable covers every arm above"),
841 })
842}
843
844fn build_uri_list(items: &[Imported]) -> String {
845 let mut out = String::new();
846 for it in items {
847 out.push_str(&it.stored.to_uri(
848 it.issuer.as_deref().unwrap_or(""),
849 it.account.as_deref().unwrap_or(""),
850 ));
851 out.push('\n');
852 }
853 out
854}
855
856fn build_aegis(items: &[Imported]) -> String {
857 let entries: Vec<Value> = items
858 .iter()
859 .map(|it| {
860 let mut info = serde_json::json!({
865 "secret": it.stored.secret,
866 "algo": it.stored.params.algorithm.as_str(),
867 "digits": it.stored.params.digits,
868 });
869 match it.stored.params.kind {
870 totp::Kind::Hotp => info["counter"] = Value::from(it.stored.params.counter),
871 totp::Kind::Totp | totp::Kind::Steam => {
872 info["period"] = Value::from(it.stored.params.period)
873 }
874 }
875 serde_json::json!({
876 "type": it.stored.params.kind.as_str(),
877 "uuid": stable_uuid(&it.stored.secret),
881 "name": it.account.clone().unwrap_or_default(),
882 "issuer": it.issuer.clone().unwrap_or_default(),
883 "note": it.note.clone().unwrap_or_default(),
884 "favorite": false,
885 "icon": Value::Null,
886 "info": info
887 })
888 })
889 .collect();
890 let doc = serde_json::json!({
893 "version": 1,
894 "header": { "slots": Value::Null, "params": Value::Null },
895 "db": { "version": 3, "entries": entries }
896 });
897 serde_json::to_string_pretty(&doc).unwrap_or_default()
898}
899
900fn build_2fas(items: &[Imported]) -> String {
901 let services: Vec<Value> = items
902 .iter()
903 .map(|it| {
904 let issuer = it.issuer.clone().unwrap_or_default();
905 let account = it.account.clone().unwrap_or_default();
906 serde_json::json!({
907 "name": if issuer.is_empty() { account.clone() } else { issuer.clone() },
908 "secret": it.stored.secret,
909 "updatedAt": 0,
910 "otp": {
911 "label": if account.is_empty() { issuer.clone() } else { account.clone() },
912 "account": account,
913 "issuer": issuer,
914 "digits": it.stored.params.digits,
915 "period": it.stored.params.period,
916 "algorithm": it.stored.params.algorithm.as_str(),
917 "tokenType": it.stored.params.kind.as_str().to_uppercase(),
920 "counter": match it.stored.params.kind {
921 totp::Kind::Hotp => Value::from(it.stored.params.counter),
922 _ => Value::Null,
923 },
924 "source": "Manual",
925 },
926 "order": { "position": 0 },
927 "icon": { "selected": "Label", "label": { "text": "", "backgroundColor": "Blue" } },
928 })
929 })
930 .collect();
931 let doc = serde_json::json!({
932 "services": services,
933 "schemaVersion": 4,
936 "appVersionCode": 0,
937 "appVersionName": "UnENVerse",
938 "appOrigin": "android",
939 "servicesEncrypted": Value::Null,
940 "reference": Value::Null,
941 });
942 serde_json::to_string_pretty(&doc).unwrap_or_default()
943}
944
945fn stable_uuid(secret: &str) -> String {
956 use sha2::{Digest, Sha256};
957 let h = Sha256::digest(format!("unenverse-aegis-uuid:{secret}").as_bytes());
958 let hex: String = h.iter().take(16).map(|b| format!("{b:02x}")).collect();
959 format!(
960 "{}-{}-{}-{}-{}",
961 &hex[0..8],
962 &hex[8..12],
963 &hex[12..16],
964 &hex[16..20],
965 &hex[20..32]
966 )
967}
968
969fn match_key(provider: &str, account: &str) -> (String, String) {
984 (
985 provider.trim().to_lowercase(),
986 account.trim().to_lowercase(),
987 )
988}
989
990#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
992#[serde(tag = "action", rename_all = "snake_case")]
993pub enum Plan {
994 Create,
996 Update { index: usize },
998 Unchanged { index: usize },
1001 Conflict { index: usize },
1004}
1005
1006pub fn plan(entries: &[Value], items: &[Imported], force: bool) -> Vec<Plan> {
1014 let existing: std::collections::HashMap<(String, String), usize> = entries
1015 .iter()
1016 .enumerate()
1017 .map(|(i, e)| {
1018 (
1019 match_key(
1020 e.get("provider").and_then(|v| v.as_str()).unwrap_or(""),
1021 e.get("account_name").and_then(|v| v.as_str()).unwrap_or(""),
1022 ),
1023 i,
1024 )
1025 })
1026 .collect();
1027
1028 items
1029 .iter()
1030 .map(|item| {
1031 let key = match_key(
1032 &item.suggested_provider(),
1033 item.account.as_deref().unwrap_or(""),
1034 );
1035 let Some(&index) = existing.get(&key) else {
1036 return Plan::Create;
1037 };
1038 let current = entries[index]
1039 .get("totp_secret")
1040 .and_then(|v| v.as_str())
1041 .map(totp::normalize_b32)
1042 .unwrap_or_default();
1043 if current == item.stored.secret {
1044 Plan::Unchanged { index }
1045 } else if current.is_empty() || force {
1046 Plan::Update { index }
1047 } else {
1048 Plan::Conflict { index }
1049 }
1050 })
1051 .collect()
1052}
1053
1054pub fn write_fields(entry: &mut Value, stored: &Stored) {
1061 let defaults = Params::default();
1062 entry["totp_secret"] = Value::String(stored.secret.clone());
1063 let Some(o) = entry.as_object_mut() else {
1064 return;
1065 };
1066 if stored.params.algorithm == defaults.algorithm {
1067 o.remove("totp_algorithm");
1068 } else {
1069 o.insert(
1070 "totp_algorithm".into(),
1071 Value::String(stored.params.algorithm.as_str().into()),
1072 );
1073 }
1074 if stored.params.digits == defaults.digits {
1075 o.remove("totp_digits");
1076 } else {
1077 o.insert("totp_digits".into(), Value::from(stored.params.digits));
1078 }
1079 if stored.params.period == defaults.period {
1080 o.remove("totp_period");
1081 } else {
1082 o.insert("totp_period".into(), Value::from(stored.params.period));
1083 }
1084 if stored.params.kind == defaults.kind {
1085 o.remove("totp_kind");
1086 } else {
1087 o.insert(
1088 "totp_kind".into(),
1089 Value::String(stored.params.kind.as_str().into()),
1090 );
1091 }
1092 if stored.params.kind == totp::Kind::Hotp {
1097 o.insert("totp_counter".into(), Value::from(stored.params.counter));
1098 } else {
1099 o.remove("totp_counter");
1100 }
1101}
1102
1103pub fn new_entry(
1111 item: &Imported,
1112 id: &str,
1113 now: &str,
1114 project: Option<&str>,
1115 category: Option<&str>,
1116) -> Value {
1117 let account = item.account.clone().unwrap_or_default();
1118 let mut projects = vec![Value::String("Universal".into())];
1119 if let Some(p) = project.filter(|p| *p != "Universal") {
1120 projects.push(Value::String(p.into()));
1121 }
1122 let mut e = serde_json::json!({
1123 "id": id,
1124 "provider": item.suggested_provider(),
1125 "api_key": "",
1126 "secretType": "password",
1127 "price_type": "free",
1128 "categories": category.map(|c| vec![Value::String(c.into())]).unwrap_or_default(),
1129 "projectIds": projects,
1130 "scopes": [],
1131 "created_at": now,
1132 });
1133 if !account.is_empty() {
1134 e["account_name"] = Value::String(account.clone());
1135 e["username"] = Value::String(account);
1137 }
1138 if let Some(note) = &item.note {
1139 e["description"] = Value::String(note.clone());
1140 }
1141 write_fields(&mut e, &item.stored);
1142 e
1143}
1144
1145#[cfg(test)]
1146mod tests {
1147 use super::*;
1148
1149 const SEED: &str = "JBSWY3DPEHPK3PXP";
1151
1152 fn one(items: &[Imported]) -> &Imported {
1153 assert_eq!(items.len(), 1, "expected exactly one item: {items:?}");
1154 &items[0]
1155 }
1156
1157 fn first(items: &[Imported]) -> &Imported {
1159 assert!(!items.is_empty(), "expected at least one item");
1160 &items[0]
1161 }
1162
1163 #[test]
1166 fn every_supported_format_is_recognised_from_its_own_bytes() {
1167 let cases: [(&str, Format); 6] = [
1171 (
1172 "otpauth://totp/Acme:me?secret=JBSWY3DPEHPK3PXP",
1173 Format::OtpauthList,
1174 ),
1175 (
1176 "otpauth-migration://offline?data=CjEKCkhlbGxvIePop-8SEmpvaG5AZXhhbXBsZS5jb20aBUFjbWUgIAEoATACEAEYASAA",
1177 Format::GoogleMigration,
1178 ),
1179 (
1180 r#"{"version":1,"header":{"slots":null,"params":null},"db":{"version":3,"entries":[]}}"#,
1181 Format::Aegis,
1182 ),
1183 (r#"{"services":[],"schemaVersion":4}"#, Format::TwoFas),
1184 (r#"[{"secret":"JBSWY3DPEHPK3PXP","label":"a"}]"#, Format::AndOtp),
1185 (r#"{"encrypted":false,"items":[]}"#, Format::Bitwarden),
1186 ];
1187 for (text, want) in cases {
1188 assert_eq!(detect(text).unwrap(), want, "detecting {want:?}");
1189 }
1190 }
1191
1192 #[test]
1193 fn an_encrypted_export_is_refused_by_name_rather_than_failing_to_parse() {
1194 let aegis = r#"{"version":1,"header":{"slots":[{"type":1}],"params":{}},"db":"AAAA"}"#;
1198 let err = detect(aegis).expect_err("encrypted Aegis must be refused");
1199 assert!(err.contains("Aegis"), "{err}");
1200 assert!(err.contains("encrypt"), "{err}");
1201
1202 let twofas = r#"{"services":[],"servicesEncrypted":"deadbeef","schemaVersion":4}"#;
1203 let err = detect(twofas).expect_err("encrypted 2FAS must be refused");
1204 assert!(err.contains("2FAS"), "{err}");
1205
1206 let bw = r#"{"encrypted":true,"items":[]}"#;
1207 let err = detect(bw).expect_err("encrypted Bitwarden must be refused");
1208 assert!(err.contains("Bitwarden"), "{err}");
1209 }
1210
1211 #[test]
1212 fn detection_never_panics_on_junk() {
1213 for junk in ["", " ", "\u{feff}", "not json {", "\0\0\0", "[]", "{}"] {
1215 let _ = detect(junk);
1216 }
1217 }
1218
1219 #[test]
1222 fn an_ente_plain_export_imports_including_its_codedisplay_parameter() {
1223 let text = concat!(
1228 "otpauth://totp/Acme:me%40example.com?secret=JBSWY3DPEHPK3PXP&issuer=Acme",
1229 "&algorithm=SHA1&digits=6&period=30",
1230 "&codeDisplay=%7B%22pinned%22%3Afalse%2C%22trashed%22%3Afalse%7D\n",
1231 "\n",
1232 "# a comment Ente does not write, but a hand-edited file might\n",
1233 "otpauth://totp/Other?secret=MZXW6YTBOI&digits=8&period=60&algorithm=SHA512\n",
1234 );
1235 let r = parse(text).unwrap();
1236 assert_eq!(r.format, Format::OtpauthList);
1237 assert_eq!(r.items.len(), 2);
1238 assert!(r.skipped.is_empty(), "{:?}", r.skipped);
1239 assert_eq!(r.items[0].stored.secret, SEED);
1240 assert_eq!(r.items[0].issuer.as_deref(), Some("Acme"));
1241 assert_eq!(r.items[0].account.as_deref(), Some("me@example.com"));
1242 assert_eq!(r.items[1].stored.params.digits, 8);
1243 assert_eq!(r.items[1].stored.params.period, 60);
1244 assert_eq!(r.items[1].stored.params.algorithm, totp::Algorithm::Sha512);
1245 }
1246
1247 #[test]
1248 fn a_bad_line_is_reported_by_line_number_and_never_by_content() {
1249 let text = "otpauth://totp/Ok?secret=JBSWY3DPEHPK3PXP\n\
1251 otpauth://totp/Bad?secret=NOTBASE32!!!\n\
1252 otpauth://hotp/Counter?secret=JBSWY3DPEHPK3PXP&counter=1\n";
1253 let r = parse(text).unwrap();
1254 assert_eq!(r.items.len(), 2);
1257 assert_eq!(r.items[1].stored.params.kind, totp::Kind::Hotp);
1258 assert_eq!(r.items[1].stored.params.counter, 1);
1259 assert_eq!(r.skipped.len(), 1);
1260 for s in &r.skipped {
1261 assert!(s.name.starts_with("line "), "{s:?}");
1262 assert!(!s.reason.contains("NOTBASE32"), "{s:?}");
1263 }
1264 }
1265
1266 #[test]
1269 fn an_aegis_vault_imports_with_its_parameters_and_note() {
1270 let text = r#"{
1271 "version": 1,
1272 "header": { "slots": null, "params": null },
1273 "db": { "version": 3, "entries": [
1274 { "type": "totp", "uuid": "x", "name": "me@example.com", "issuer": "Acme",
1275 "note": "work laptop", "favorite": false, "icon": null,
1276 "info": { "secret": "JBSWY3DPEHPK3PXP", "algo": "SHA256", "digits": 8, "period": 60 } },
1277 { "type": "hotp", "uuid": "y", "name": "counter", "issuer": "Old",
1278 "info": { "secret": "JBSWY3DPEHPK3PXP", "algo": "SHA1", "digits": 6, "counter": 3 } }
1279 ] }
1280 }"#;
1281 let r = parse(text).unwrap();
1282 assert_eq!(r.format, Format::Aegis);
1283 assert_eq!(r.items.len(), 2);
1284 let it = first(&r.items);
1285 assert_eq!(it.stored.secret, SEED);
1286 assert_eq!(it.stored.params.algorithm, totp::Algorithm::Sha256);
1287 assert_eq!(it.stored.params.digits, 8);
1288 assert_eq!(it.stored.params.period, 60);
1289 assert_eq!(it.issuer.as_deref(), Some("Acme"));
1290 assert_eq!(it.account.as_deref(), Some("me@example.com"));
1291 assert_eq!(it.note.as_deref(), Some("work laptop"));
1292 assert_eq!(r.items[1].stored.params.kind, totp::Kind::Hotp);
1295 assert_eq!(r.items[1].stored.params.counter, 3);
1296 assert!(r.skipped.is_empty(), "{:?}", r.skipped);
1297 }
1298
1299 #[test]
1300 fn an_aegis_steam_entry_keeps_steams_shape() {
1301 let text = r#"{
1306 "version": 1,
1307 "header": { "slots": null, "params": null },
1308 "db": { "version": 3, "entries": [
1309 { "type": "steam", "uuid": "s", "name": "me", "issuer": "Steam",
1310 "info": { "secret": "JBSWY3DPEHPK3PXP", "algo": "SHA1", "digits": 5, "period": 30 } }
1311 ] }
1312 }"#;
1313 let r = parse(text).unwrap();
1314 let it = one(&r.items);
1315 assert_eq!(it.stored.params.kind, totp::Kind::Steam);
1316 assert_eq!(it.stored.params.digits, totp::STEAM_DIGITS);
1317
1318 for fmt in [Format::Aegis, Format::OtpauthList] {
1320 let written = build(&r.items, fmt).expect("steam is exportable");
1321 let back = parse(&written).expect("what we wrote, we read");
1322 assert_eq!(
1323 back.items[0].stored.params.kind,
1324 totp::Kind::Steam,
1325 "{fmt:?} lost the Steam encoder"
1326 );
1327 }
1328 }
1329
1330 #[test]
1331 fn a_counter_survives_a_round_trip_through_every_writable_format() {
1332 let items = vec![Imported {
1337 issuer: Some("Acme".into()),
1338 account: Some("me".into()),
1339 stored: Stored {
1340 secret: SEED.into(),
1341 params: Params {
1342 kind: totp::Kind::Hotp,
1343 counter: 41,
1344 ..Params::default()
1345 },
1346 issuer: Some("Acme".into()),
1347 account: Some("me".into()),
1348 },
1349 note: None,
1350 }];
1351 for fmt in Format::EXPORTABLE {
1352 let written = build(&items, fmt).expect("writable");
1353 let back = parse(&written).unwrap_or_else(|e| panic!("{fmt:?}: {e}"));
1354 assert_eq!(back.items.len(), 1, "{fmt:?}");
1355 assert_eq!(
1356 back.items[0].stored.params.kind,
1357 totp::Kind::Hotp,
1358 "{fmt:?}"
1359 );
1360 assert_eq!(back.items[0].stored.params.counter, 41, "{fmt:?}");
1361 }
1362 }
1363
1364 #[test]
1365 fn a_2fas_backup_imports_from_either_place_it_keeps_the_name() {
1366 let text = r#"{
1369 "services": [
1370 { "name": "Acme", "secret": "JBSWY3DPEHPK3PXP",
1371 "otp": { "label": "me", "account": "me", "issuer": "Acme",
1372 "digits": 6, "period": 30, "algorithm": "SHA1", "tokenType": "TOTP" } },
1373 { "name": "Fallback", "secret": "MZXW6YTBOI",
1374 "otp": { "label": "lbl", "tokenType": "TOTP" } },
1375 { "name": "Counter", "secret": "JBSWY3DPEHPK3PXP",
1376 "otp": { "label": "c", "tokenType": "HOTP" } }
1377 ],
1378 "schemaVersion": 4
1379 }"#;
1380 let r = parse(text).unwrap();
1381 assert_eq!(r.format, Format::TwoFas);
1382 assert_eq!(r.items.len(), 3);
1384 assert_eq!(r.items[0].issuer.as_deref(), Some("Acme"));
1385 assert_eq!(r.items[1].issuer.as_deref(), Some("Fallback"));
1387 assert_eq!(r.items[1].account.as_deref(), Some("lbl"));
1388 assert!(r.items[1].stored.params.is_default());
1390 assert_eq!(r.items[2].stored.params.kind, totp::Kind::Hotp);
1391 assert!(r.skipped.is_empty(), "{:?}", r.skipped);
1392 }
1393
1394 #[test]
1395 fn an_andotp_export_imports_with_its_tags_as_the_note() {
1396 let text = r#"[
1397 { "secret": "JBSWY3DPEHPK3PXP", "issuer": "Acme", "label": "me",
1398 "digits": 6, "type": "TOTP", "algorithm": "SHA1", "period": 30,
1399 "tags": ["work", "critical"] }
1400 ]"#;
1401 let r = parse(text).unwrap();
1402 assert_eq!(r.format, Format::AndOtp);
1403 let it = one(&r.items);
1404 assert_eq!(it.stored.secret, SEED);
1405 assert_eq!(it.note.as_deref(), Some("work, critical"));
1406 }
1407
1408 #[test]
1409 fn a_bitwarden_export_reads_login_totp_in_both_spellings() {
1410 let text = r#"{ "encrypted": false, "items": [
1413 { "type": 1, "name": "Plain", "login": { "username": "u1", "totp": "JBSWY3DPEHPK3PXP" } },
1414 { "type": 1, "name": "Uri", "login": { "username": "u2",
1415 "totp": "otpauth://totp/Ignored:x?secret=MZXW6YTBOI&digits=8" } },
1416 { "type": 1, "name": "NoTotp", "login": { "username": "u3", "password": "p" } },
1417 { "type": 2, "name": "SecureNote" }
1418 ] }"#;
1419 let r = parse(text).unwrap();
1420 assert_eq!(r.format, Format::Bitwarden);
1421 assert_eq!(r.items.len(), 2);
1422 assert_eq!(r.items[0].stored.secret, SEED);
1423 assert_eq!(r.items[0].account.as_deref(), Some("u1"));
1424 assert_eq!(r.items[1].stored.params.digits, 8);
1425 assert_eq!(r.items[1].issuer.as_deref(), Some("Uri"));
1428 assert!(r.skipped.is_empty(), "{:?}", r.skipped);
1431 }
1432
1433 fn google_payload(
1438 secret: &[u8],
1439 name: &str,
1440 issuer: &str,
1441 algo: u8,
1442 digits: u8,
1443 kind: u8,
1444 ) -> String {
1445 fn varint(out: &mut Vec<u8>, mut v: u64) {
1446 loop {
1447 let mut b = (v & 0x7f) as u8;
1448 v >>= 7;
1449 if v != 0 {
1450 b |= 0x80;
1451 }
1452 out.push(b);
1453 if v == 0 {
1454 break;
1455 }
1456 }
1457 }
1458 fn field_bytes(out: &mut Vec<u8>, num: u64, data: &[u8]) {
1459 varint(out, (num << 3) | 2);
1460 varint(out, data.len() as u64);
1461 out.extend_from_slice(data);
1462 }
1463 fn field_varint(out: &mut Vec<u8>, num: u64, v: u64) {
1464 varint(out, num << 3);
1465 varint(out, v);
1466 }
1467
1468 let mut params = Vec::new();
1469 field_bytes(&mut params, 1, secret);
1470 field_bytes(&mut params, 2, name.as_bytes());
1471 field_bytes(&mut params, 3, issuer.as_bytes());
1472 field_varint(&mut params, 4, u64::from(algo));
1473 field_varint(&mut params, 5, u64::from(digits));
1474 field_varint(&mut params, 6, u64::from(kind));
1475
1476 let mut payload = Vec::new();
1477 field_bytes(&mut payload, 1, ¶ms);
1478 field_varint(&mut payload, 2, 1); field_varint(&mut payload, 3, 1); field_varint(&mut payload, 4, 0); let b64 = base64::engine::general_purpose::STANDARD.encode(&payload);
1483 let pct: String = b64
1484 .chars()
1485 .map(|c| match c {
1486 '+' => "%2B".to_string(),
1487 '/' => "%2F".to_string(),
1488 '=' => "%3D".to_string(),
1489 c => c.to_string(),
1490 })
1491 .collect();
1492 format!("otpauth-migration://offline?data={pct}")
1493 }
1494
1495 #[test]
1496 fn a_google_migration_payload_decodes_to_the_same_seed_google_encoded() {
1497 let raw = totp::base32_decode(SEED).unwrap();
1502 let uri = google_payload(&raw, "me@example.com", "Acme", 1, 1, 2);
1503 let r = parse(&uri).unwrap();
1504 assert_eq!(r.format, Format::GoogleMigration);
1505 let it = one(&r.items);
1506 assert_eq!(it.stored.secret, SEED);
1507 assert_eq!(it.issuer.as_deref(), Some("Acme"));
1508 assert_eq!(it.account.as_deref(), Some("me@example.com"));
1509 assert!(it.stored.params.is_default());
1511 }
1512
1513 #[test]
1514 fn the_migration_enums_map_to_the_right_algorithm_and_digit_count() {
1515 let raw = totp::base32_decode(SEED).unwrap();
1516 for (algo_enum, want) in [
1517 (0u8, totp::Algorithm::Sha1), (1, totp::Algorithm::Sha1),
1519 (2, totp::Algorithm::Sha256),
1520 (3, totp::Algorithm::Sha512),
1521 ] {
1522 let uri = google_payload(&raw, "n", "i", algo_enum, 1, 2);
1523 assert_eq!(
1524 parse(&uri).unwrap().items[0].stored.params.algorithm,
1525 want,
1526 "algorithm enum {algo_enum}"
1527 );
1528 }
1529 let six = google_payload(&raw, "n", "i", 1, 1, 2);
1531 let eight = google_payload(&raw, "n", "i", 1, 2, 2);
1532 assert_eq!(parse(&six).unwrap().items[0].stored.params.digits, 6);
1533 assert_eq!(parse(&eight).unwrap().items[0].stored.params.digits, 8);
1534 }
1535
1536 #[test]
1537 fn a_counter_based_migration_entry_is_imported_with_its_counter() {
1538 let raw = totp::base32_decode(SEED).unwrap();
1543 let uri = google_payload(&raw, "counter", "Old", 1, 1, 1); let r = parse(&uri).unwrap();
1545 assert_eq!(r.items.len(), 1);
1546 assert!(r.skipped.is_empty(), "{:?}", r.skipped);
1547 assert_eq!(r.items[0].stored.params.kind, totp::Kind::Hotp);
1548 }
1549
1550 #[test]
1551 fn several_migration_uris_accumulate_rather_than_conflicting() {
1552 let raw = totp::base32_decode(SEED).unwrap();
1555 let text = format!(
1556 "{}\n{}\n",
1557 google_payload(&raw, "a", "One", 1, 1, 2),
1558 google_payload(&raw, "b", "Two", 1, 1, 2)
1559 );
1560 let r = parse(&text).unwrap();
1561 assert_eq!(r.items.len(), 2);
1562 assert_eq!(r.items[1].issuer.as_deref(), Some("Two"));
1563 }
1564
1565 #[test]
1566 fn a_truncated_or_junk_migration_payload_errors_rather_than_panicking() {
1567 for bad in [
1570 "otpauth-migration://offline?data=",
1571 "otpauth-migration://offline?data=!!!!",
1572 "otpauth-migration://offline?data=CjEKCg", "otpauth-migration://offline?data=%FF%FF%FF%FF",
1574 ] {
1575 let _ = parse(bad);
1576 }
1577 let payload = base64::engine::general_purpose::STANDARD.encode([0x0a, 0x7f, 0x01]);
1579 let r = parse(&format!("otpauth-migration://offline?data={payload}"));
1580 assert!(r.is_err(), "a lying length prefix must not be accepted");
1581 }
1582
1583 fn sample() -> Vec<Imported> {
1586 vec![
1587 Imported {
1588 issuer: Some("Acme".into()),
1589 account: Some("me@example.com".into()),
1590 stored: Stored {
1591 secret: SEED.into(),
1592 params: Params::default(),
1593 issuer: None,
1594 account: None,
1595 },
1596 note: Some("work".into()),
1597 },
1598 Imported {
1599 issuer: Some("Other".into()),
1600 account: None,
1601 stored: Stored {
1602 secret: "MZXW6YTBOI".into(),
1603 params: Params {
1604 kind: totp::Kind::Totp,
1605 counter: 0,
1606 algorithm: totp::Algorithm::Sha512,
1607 digits: 8,
1608 period: 45,
1609 },
1610 issuer: None,
1611 account: None,
1612 },
1613 note: None,
1614 },
1615 ]
1616 }
1617
1618 #[test]
1619 fn every_exportable_format_reads_back_through_this_modules_own_parser() {
1620 for format in Format::EXPORTABLE {
1624 let text = build(&sample(), format).unwrap();
1625 let back = parse(&text)
1626 .unwrap_or_else(|e| panic!("{} did not read back: {e}", format.as_str()));
1627 assert_eq!(
1628 back.format, format,
1629 "detected the wrong format for {format:?}"
1630 );
1631 assert_eq!(back.items.len(), 2, "{format:?}");
1632 for (a, b) in sample().iter().zip(back.items.iter()) {
1633 assert_eq!(a.stored.secret, b.stored.secret, "{format:?}");
1634 assert_eq!(a.stored.params, b.stored.params, "{format:?}");
1635 assert_eq!(a.issuer, b.issuer, "{format:?}");
1636 }
1637 }
1638 }
1639
1640 #[test]
1641 fn a_read_only_format_is_refused_by_name_with_the_alternatives() {
1642 for format in [Format::Bitwarden, Format::GoogleMigration, Format::AndOtp] {
1643 let err = build(&sample(), format).expect_err("must refuse");
1644 assert!(err.contains(format.as_str()), "{err}");
1645 assert!(err.contains("otpauth"), "{err}");
1646 }
1647 }
1648
1649 #[test]
1650 fn the_aegis_uuid_is_stable_across_builds_so_a_re_import_updates() {
1651 let a = build(&sample(), Format::Aegis).unwrap();
1654 let b = build(&sample(), Format::Aegis).unwrap();
1655 assert_eq!(a, b);
1656 assert!(!a.contains(&format!("\"uuid\": \"{SEED}\"")));
1658 }
1659
1660 #[test]
1661 fn aegis_marks_the_vault_unencrypted_the_way_aegis_requires() {
1662 let v: Value = serde_json::from_str(&build(&sample(), Format::Aegis).unwrap()).unwrap();
1665 assert!(v.pointer("/header/slots").unwrap().is_null());
1666 assert_eq!(
1667 v.pointer("/db/entries").unwrap().as_array().unwrap().len(),
1668 2
1669 );
1670 }
1671
1672 #[test]
1673 fn format_names_round_trip_and_ente_is_an_alias_for_the_uri_list() {
1674 for f in [
1675 Format::OtpauthList,
1676 Format::Aegis,
1677 Format::TwoFas,
1678 Format::AndOtp,
1679 Format::Bitwarden,
1680 Format::GoogleMigration,
1681 ] {
1682 assert_eq!(Format::parse(f.as_str()), Some(f), "{f:?}");
1683 }
1684 assert_eq!(Format::parse("ente"), Some(Format::OtpauthList));
1687 assert_eq!(Format::parse("Ente"), Some(Format::OtpauthList));
1688 assert_eq!(Format::parse("2FAS"), Some(Format::TwoFas));
1689 assert_eq!(
1690 Format::parse("google-authenticator"),
1691 Some(Format::GoogleMigration)
1692 );
1693 assert_eq!(Format::parse("nonsense"), None);
1694 }
1695
1696 #[test]
1697 fn suggested_provider_prefers_the_issuer_over_an_email_address() {
1698 let mut it = sample().remove(0);
1701 assert_eq!(it.suggested_provider(), "Acme");
1702 it.issuer = None;
1703 assert_eq!(it.suggested_provider(), "me@example.com");
1704 it.account = Some(" ".into());
1705 assert_eq!(it.suggested_provider(), "Unnamed");
1706 }
1707
1708 fn entry(provider: &str, account: &str, seed: Option<&str>) -> Value {
1711 let mut e = serde_json::json!({
1712 "id": provider, "provider": provider, "api_key": "",
1713 "account_name": account, "categories": [], "projectIds": ["Universal"], "scopes": []
1714 });
1715 if let Some(s) = seed {
1716 e["totp_secret"] = Value::String(s.into());
1717 }
1718 e
1719 }
1720
1721 fn incoming(issuer: &str, account: &str, seed: &str) -> Imported {
1722 Imported {
1723 issuer: Some(issuer.into()),
1724 account: Some(account.into()),
1725 stored: totp::parse_seed(seed).expect("fixture seed parses"),
1726 note: None,
1727 }
1728 }
1729
1730 const OTHER: &str = "MZXW6YTBOI";
1731
1732 #[test]
1733 fn an_unmatched_account_is_created() {
1734 assert_eq!(
1735 plan(&[], &[incoming("Acme", "me", SEED)], false),
1736 [Plan::Create]
1737 );
1738 }
1739
1740 #[test]
1741 fn an_entry_with_no_seed_yet_is_updated_rather_than_duplicated() {
1742 let vault = [entry("Acme", "me", None)];
1746 assert_eq!(
1747 plan(&vault, &[incoming("Acme", "me", SEED)], false),
1748 [Plan::Update { index: 0 }]
1749 );
1750 }
1751
1752 #[test]
1753 fn re_running_the_same_import_changes_nothing() {
1754 let vault = [entry("Acme", "me", Some(SEED))];
1756 assert_eq!(
1757 plan(&vault, &[incoming("Acme", "me", SEED)], false),
1758 [Plan::Unchanged { index: 0 }]
1759 );
1760 }
1761
1762 #[test]
1763 fn a_different_stored_seed_is_a_conflict_and_not_an_overwrite() {
1764 let vault = [entry("Acme", "me", Some(SEED))];
1769 assert_eq!(
1770 plan(&vault, &[incoming("Acme", "me", OTHER)], false),
1771 [Plan::Conflict { index: 0 }]
1772 );
1773 assert_eq!(
1774 plan(&vault, &[incoming("Acme", "me", OTHER)], true),
1775 [Plan::Update { index: 0 }],
1776 "--force is how you say you meant it"
1777 );
1778 }
1779
1780 #[test]
1781 fn matching_needs_both_the_provider_and_the_account() {
1782 let vault = [entry("Acme", "me", Some(SEED)), entry("Other", "me", None)];
1786 assert_eq!(
1787 plan(
1788 &vault,
1789 &[
1790 incoming("Acme", "someone-else", OTHER),
1791 incoming("Other", "me", OTHER),
1792 ],
1793 false
1794 ),
1795 [Plan::Create, Plan::Update { index: 1 }]
1796 );
1797 }
1798
1799 #[test]
1800 fn matching_ignores_case_and_surrounding_space() {
1801 let vault = [entry("GitHub", "Me@Example.com", None)];
1804 assert_eq!(
1805 plan(
1806 &vault,
1807 &[incoming(" github ", "me@example.com", SEED)],
1808 false
1809 ),
1810 [Plan::Update { index: 0 }]
1811 );
1812 }
1813
1814 #[test]
1815 fn a_seed_spelled_differently_is_still_the_same_seed() {
1816 let vault = [entry("Acme", "me", Some("jbsw y3dp ehpk 3pxp"))];
1819 assert_eq!(
1820 plan(&vault, &[incoming("Acme", "me", SEED)], false),
1821 [Plan::Unchanged { index: 0 }]
1822 );
1823 }
1824
1825 #[test]
1826 fn write_fields_clears_a_parameter_that_returned_to_its_default() {
1827 let mut e = serde_json::json!({ "provider": "A", "totp_period": 60, "totp_digits": 8 });
1831 write_fields(
1832 &mut e,
1833 &Stored {
1834 secret: SEED.into(),
1835 params: Params::default(),
1836 issuer: None,
1837 account: None,
1838 },
1839 );
1840 assert_eq!(e["totp_secret"], SEED);
1841 assert!(e.get("totp_period").is_none(), "{e}");
1842 assert!(e.get("totp_digits").is_none(), "{e}");
1843 }
1844
1845 #[test]
1846 fn a_new_entry_carries_the_account_into_username_and_stays_in_universal() {
1847 let it = incoming("Acme", "me@example.com", SEED);
1848 let e = new_entry(
1849 &it,
1850 "id-1",
1851 "2026-09-10T00:00:00Z",
1852 Some("web"),
1853 Some("2fa"),
1854 );
1855 assert_eq!(e["provider"], "Acme");
1856 assert_eq!(e["secretType"], "password");
1857 assert_eq!(e["api_key"], "");
1858 assert_eq!(e["account_name"], "me@example.com");
1859 assert_eq!(e["username"], "me@example.com");
1860 assert_eq!(e["totp_secret"], SEED);
1861 assert_eq!(e["categories"], serde_json::json!(["2fa"]));
1862 assert_eq!(e["projectIds"], serde_json::json!(["Universal", "web"]));
1864 }
1865}