qualia2fa_core/export/mod.rs
1// Copyright (C) 2026 Qualia2FA contributors
2//
3// This library is free software; you can redistribute it and/or modify it under
4// the terms of the GNU Lesser General Public License as published by the Free
5// Software Foundation; either version 3 of the License, or (at your option) any
6// later version.
7//
8// This library is distributed in the hope that it will be useful, but WITHOUT
9// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
10// FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more
11// details.
12//
13// You should have received a copy of the GNU Lesser General Public License
14// along with this library; if not, see <https://www.gnu.org/licenses/>.
15
16//! Writing the vault out in a form that can be read again somewhere else.
17//!
18//! # Two kinds of export, and they are not interchangeable
19//!
20//! - **A portable vault** ([`ExportFormat::Vault`], [`ExportFormat::EncryptedVault`]) is this
21//! application's own backup: the master key and the whole encrypted vault, in one `.q2fa`
22//! file. It restores everything - entries, the group **tree**, the icons - on a clean device.
23//! - **An export for another authenticator** ([`ExportFormat::Text`], [`ExportFormat::Json`],
24//! [`ExportFormat::EncryptedJson`], [`ExportFormat::Html`]) is written in the shapes the
25//! reference implementation reads. What each of them can carry is stated by
26//! [`ExportFormat::group_carrying`]: the text and HTML forms have no group at all, and even
27//! the JSON forms carry only one flat level of group names, not the nesting.
28//!
29//! # The formats are the reference implementation's, not this library's
30//!
31//! An export exists to be read by something else, so its spelling is decided by the reader
32//! rather than by what would be convenient here: the JSON object, the table of the HTML
33//! export, the one-URI-per-line text file and the `motp:` form of an mOTP URI are all the
34//! shapes the reference implementation writes and reads. Nothing is added, and nothing is
35//! renamed. In particular the icon fields of the JSON export are `icon` (base64), `icon_mime`
36//! and `icon_hash`, exactly as the reference format spells them.
37//!
38//! # An export round trips through this library's own reader
39//!
40//! Every external format here has a reader in [`crate::import`], and each writer's tests walk the
41//! whole way: vault โ file โ reader โ entry by entry comparison. That is the property that
42//! matters, because the two halves were written against the same document and a shared
43//! misunderstanding would otherwise stay invisible.
44//!
45//! What the round trip cannot preserve is what a format does not carry: the text and HTML
46//! exports have no field for the groups or for the ordering, neither has one for the account
47//! name (a URI carries it in the label), and the JSON forms lose the nesting. Each writer's
48//! documentation says which fields survive and which do not.
49//!
50//! # Nothing about an entry appears in an error
51//!
52//! The same rule as the reader's: an error is a position and a reason. An export that cannot
53//! be written says which entry it stopped at, never what the entry held.
54//!
55//! # The vault is not modified
56//!
57//! Every entry point takes a `&Vault` or a `&VaultFile` and returns the file's bytes; the caller
58//! writes them wherever it likes. This library has no I/O (see the crate documentation), and an
59//! export is not an exception.
60//!
61//! # The plain `.q2fa` container holds the master key in clear
62//!
63//! That is what lets it restore an account without the old device or a passphrase. It also means
64//! the file is as sensitive as every secret in the vault, and the interface must say so. This
65//! library cannot make the format safer than it is; it only refuses to write it silently under
66//! another name.
67
68pub mod html;
69pub mod json;
70pub(crate) mod png;
71pub mod text;
72
73use std::collections::HashSet;
74
75use crate::vault::{Entry, EntryId, Group, GroupId, Vault, VaultFile};
76use crate::{Error, Result};
77
78/// An export format this library writes.
79///
80/// The six are the interface's numbered choices, in the order the interface offers them: the two
81/// `.q2fa` containers first, then the four shapes another authenticator reads. Which one a
82/// caller offers is a user interface question; the differences that matter to the library are
83/// [`ExportFormat::needs_password`] and [`ExportFormat::group_carrying`].
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85pub enum ExportFormat {
86 /// A plain `.q2fa`: the master key in clear plus the whole vault ciphertext.
87 ///
88 /// This is the format that restores an account on a clean device. Whoever holds the file
89 /// holds the master key, which is why the interface warns about it more strongly than about
90 /// the encrypted form.
91 Vault,
92 /// A passphrase-protected `.q2fa`: the master key wrapped by [`crate::recovery`] plus the
93 /// whole vault ciphertext.
94 EncryptedVault,
95 /// The text export: one `otpauth://` URI per line.
96 Text,
97 /// The JSON export, readable as it stands.
98 Json,
99 /// The JSON export, encrypted with a password the caller supplies.
100 EncryptedJson,
101 /// The HTML export: a table with one row per entry and the code drawn beside it.
102 Html,
103}
104
105/// How much of the group tree a format can carry.
106///
107/// A boolean is too coarse: the Aegis JSON carries the group **names and memberships** but not
108/// the nesting, so a caller that offered it as "keeps your groups" would be saying something
109/// false. The interface labels each format from this answer.
110#[derive(Debug, Clone, Copy, PartialEq, Eq)]
111pub enum GroupCarrying {
112 /// The format has nowhere to put a group.
113 None,
114 /// The format carries the named groups and which entry belongs to which, as one flat level.
115 FlatGroups,
116 /// The format carries the whole tree: parents, nesting and the mixed order of children.
117 FullTree,
118}
119
120impl GroupCarrying {
121 /// Whether the format carries anything about groups.
122 #[must_use]
123 pub const fn carries_anything(self) -> bool {
124 !matches!(self, Self::None)
125 }
126
127 /// Whether the format keeps the nesting.
128 #[must_use]
129 pub const fn carries_nesting(self) -> bool {
130 matches!(self, Self::FullTree)
131 }
132}
133
134impl ExportFormat {
135 /// Every format, in the order the interface offers them.
136 pub const ALL: [Self; 6] = [
137 Self::Vault,
138 Self::EncryptedVault,
139 Self::Text,
140 Self::Json,
141 Self::EncryptedJson,
142 Self::Html,
143 ];
144
145 /// The name used in messages and diagnostics.
146 #[must_use]
147 pub const fn name(self) -> &'static str {
148 match self {
149 Self::Vault => "vault",
150 Self::EncryptedVault => "encrypted vault",
151 Self::Text => "text",
152 Self::Json => "JSON",
153 Self::EncryptedJson => "encrypted JSON",
154 Self::Html => "HTML",
155 }
156 }
157
158 /// The file name extension this format is normally written with.
159 ///
160 /// Both JSON forms share one: they are the same file with and without a password, and a
161 /// reader tells them apart by the `db` field being a string rather than an object. Both
162 /// `.q2fa` forms share one for the same reason: they are told apart by their magic.
163 #[must_use]
164 pub const fn extension(self) -> &'static str {
165 match self {
166 Self::Vault | Self::EncryptedVault => "q2fa",
167 Self::Text => "txt",
168 Self::Json | Self::EncryptedJson => "json",
169 Self::Html => "html",
170 }
171 }
172
173 /// Whether this format is written with a password.
174 ///
175 /// A caller uses it to decide whether to ask for one **before** calling [`export`], rather
176 /// than calling and handling [`Error::ExportPasswordRequired`].
177 #[must_use]
178 pub const fn needs_password(self) -> bool {
179 matches!(self, Self::EncryptedVault | Self::EncryptedJson)
180 }
181
182 /// How much of the group tree this format carries.
183 #[must_use]
184 pub const fn group_carrying(self) -> GroupCarrying {
185 match self {
186 Self::Vault | Self::EncryptedVault => GroupCarrying::FullTree,
187 Self::Json | Self::EncryptedJson => GroupCarrying::FlatGroups,
188 Self::Text | Self::Html => GroupCarrying::None,
189 }
190 }
191
192 /// Whether this format carries the groups at all.
193 #[must_use]
194 pub const fn carries_groups(self) -> bool {
195 self.group_carrying().carries_anything()
196 }
197}
198
199/// Writes the vault out in one of the formats.
200///
201/// `password` is the UTF-8 bytes of the password, and is looked at only by
202/// [`ExportFormat::EncryptedVault`] and [`ExportFormat::EncryptedJson`]: the derivations consume
203/// bytes, and hashing text would mean choosing a normalization the format does not specify. A
204/// password supplied for any other format is ignored rather than refused, since the caller has
205/// already chosen the format and there is nothing it could open.
206///
207/// # It takes the whole vault file, not only its contents
208///
209/// The two `.q2fa` containers carry the **master key** beside the ciphertext, so the export
210/// cannot be written from the decrypted contents alone. Passing the [`VaultFile`] keeps the key
211/// and the contents together, which is also what stops "this file was written with another
212/// key".
213///
214/// # Errors
215///
216/// - [`Error::ExportPasswordRequired`]: an encrypted export was asked for with no password.
217/// - [`Error::ExportPasswordEmpty`]: the password is empty, which would leave the file open to
218/// anyone who tries nothing at all.
219/// - [`Error::UriAccountContainsColon`]: one entry's account name cannot be written into a URI
220/// label (see [`crate::uri::serialize`]). Reported rather than worked around: the file would
221/// read back as a different entry.
222/// - [`Error::ExportQrTooLong`]: one entry's URI does not fit a QR code, so the HTML export
223/// cannot draw it.
224/// - [`Error::FieldTooLong`]: an entry holds a field the file format cannot represent.
225/// Reachable only for an entry built by hand; the vault's own parser would have refused it.
226/// - [`Error::ExportTooLarge`]: the vault does not fit a portable container.
227/// - [`Error::RandomnessUnavailable`]: a salt or a nonce could not be drawn.
228pub fn export(file: &VaultFile, format: ExportFormat, password: Option<&[u8]>) -> Result<Vec<u8>> {
229 let vault = file.vault();
230 let bytes = match format {
231 ExportFormat::Vault => {
232 // No password: this format is defined as the file that restores without one.
233 portable_bytes(file)?
234 }
235 ExportFormat::EncryptedVault => {
236 let password = password.ok_or(Error::ExportPasswordRequired)?;
237 if password.is_empty() {
238 return Err(Error::ExportPasswordEmpty);
239 }
240 let passphrase =
241 core::str::from_utf8(password).map_err(|_| Error::ExportParameterRefused {
242 parameter: "the passphrase is not UTF-8",
243 })?;
244 let recovery = crate::recovery::export(
245 file.master_key(),
246 &crate::SecretString::from(passphrase.to_owned()),
247 &crate::recovery::KdfParams::recommended(),
248 )?;
249 let data = file.serialize()?;
250 crate::portable::pack_encrypted(&recovery, &data)?
251 }
252 ExportFormat::Text => text::write(vault)?.into_bytes(),
253 ExportFormat::Json => json::write(vault)?.into_bytes(),
254 ExportFormat::Html => html::write(vault)?.into_bytes(),
255 ExportFormat::EncryptedJson => {
256 let password = password.ok_or(Error::ExportPasswordRequired)?;
257 if password.is_empty() {
258 return Err(Error::ExportPasswordEmpty);
259 }
260 json::write_encrypted(vault, password)?.into_bytes()
261 }
262 };
263 Ok(bytes)
264}
265
266/// Packs the plain `.q2fa` container: the master key in clear plus the vault ciphertext.
267///
268/// # Errors
269///
270/// As [`VaultFile::serialize`] and [`crate::portable::pack_plain`].
271fn portable_bytes(file: &VaultFile) -> Result<Vec<u8>> {
272 let data = file.serialize()?;
273 crate::portable::pack_plain(file.master_key(), &data)
274}
275
276/// One entry, with what the writers need beside it.
277pub(crate) struct Item<'a> {
278 /// The entry's identifier, which the JSON export writes as its UUID.
279 pub(crate) id: EntryId,
280 /// The entry itself.
281 pub(crate) entry: &'a Entry,
282 /// The named group it belongs to, or `None` for the top group.
283 pub(crate) group: Option<GroupId>,
284 /// Where it sits in the export, counting from 1. Used in errors, never its name.
285 pub(crate) position: usize,
286}
287
288/// Walks the vault in the order an export writes it.
289///
290/// The order is the one the user sees: the tree in pre-order, and inside each group the
291/// arrangement the vault holds - entries and child groups mixed. That is the same walk the
292/// interface performs, which is what makes an exported file look like the application.
293///
294/// Each item's `group` is the **immediate** parent: the Aegis format has one flat level, so an
295/// entry inside a nested group is written as belonging to that group by name. The nesting is
296/// lost there by definition, which is what [`GroupCarrying::FlatGroups`] says.
297///
298/// # An entry no ordering mentions
299///
300/// Every path through this library puts an entry into a group (see
301/// [`crate::vault::Vault::insert`]), so this cannot normally happen. It is handled anyway,
302/// because the alternative is an export that silently leaves entries out: whatever is left
303/// after the walk is appended at the end, with no group.
304pub(crate) fn items(vault: &Vault) -> Vec<Item<'_>> {
305 let mut out: Vec<Item<'_>> = Vec::with_capacity(vault.len());
306 let mut seen: HashSet<EntryId> = HashSet::with_capacity(vault.len());
307
308 for (group_id, _) in vault.groups() {
309 let named = (!group_id.is_top()).then_some(*group_id);
310 for (id, entry) in vault.entries_in(group_id) {
311 if !seen.insert(*id) {
312 continue;
313 }
314 out.push(Item {
315 id: *id,
316 entry,
317 group: named,
318 position: out.len() + 1,
319 });
320 }
321 }
322
323 for (id, entry) in vault.iter() {
324 if !seen.insert(*id) {
325 continue;
326 }
327 out.push(Item {
328 id: *id,
329 entry,
330 group: None,
331 position: out.len() + 1,
332 });
333 }
334
335 out
336}
337
338/// The named groups, in the order the vault displays them.
339///
340/// The top group is left out: it is not a group a file can name, it is "everything that is in
341/// no named group", and the reference implementation's format has no record for it.
342///
343/// The list is **flat**: a nested group appears beside its parent, and an entry names the group
344/// it is directly in. That is the most the reference format can carry, which is why
345/// [`ExportFormat::Json`] reports [`GroupCarrying::FlatGroups`] and not
346/// [`GroupCarrying::FullTree`].
347pub(crate) fn named_groups(vault: &Vault) -> Vec<(GroupId, &Group)> {
348 vault
349 .groups()
350 .filter(|(id, _)| !id.is_top())
351 .map(|(id, group)| (*id, group))
352 .collect()
353}
354
355/// The URI of one entry, in the spelling the reference implementation writes.
356///
357/// # The label is the account, and the name is the fallback
358///
359/// A URI has one label and the reference format has no second field, so an entry with both a
360/// name and an account can carry only one of them. The account wins because it is what the
361/// label means (it is where the account came from when the URI was read), and the name is used
362/// when there is none - an entry created in this application has a name from the start and
363/// often no account at all.
364///
365/// # Errors
366///
367/// [`crate::uri::serialize`]'s: an account name containing `:` cannot be told apart from the
368/// issuer separator once the label is decoded, so the URI is refused rather than written into
369/// something that reads back differently.
370pub(crate) fn uri_of(entry: &Entry) -> Result<String> {
371 // The copy is explicit because `OtpSecret` is deliberately neither `Clone` nor copyable by
372 // accident: `Entry::duplicate` is the one sanctioned way to get a second, owned entry.
373 let Entry {
374 name,
375 issuer,
376 account,
377 secret,
378 algorithm,
379 digits,
380 kind,
381 ..
382 } = entry.duplicate()?;
383
384 let label = if account.is_empty() { name } else { account };
385 crate::uri::serialize(&crate::uri::OtpAuth {
386 kind,
387 secret,
388 issuer,
389 account: label,
390 algorithm,
391 digits,
392 })
393}
394
395/// The name the formats use for a scheme.
396pub(crate) const fn type_name(kind: &crate::vault::EntryKind) -> &'static str {
397 use crate::vault::EntryKind;
398 match kind {
399 EntryKind::Totp { .. } => "totp",
400 EntryKind::Hotp { .. } => "hotp",
401 EntryKind::Steam => "steam",
402 EntryKind::Motp { .. } => "motp",
403 EntryKind::Yandex { .. } => "yandex",
404 }
405}
406
407/// The hash algorithm to write for an entry.
408///
409/// # The scheme wins over the entry's own field
410///
411/// Three of the five schemes fix their own algorithm: mOTP is MD5-based, Yandex OTP is SHA-256,
412/// Steam Guard is SHA-1. The entry model keeps a field there anyway (it is one type for every
413/// entry), and what decides the code a user sees is the scheme - so that is what is written.
414/// Writing the field instead would produce a file whose declared algorithm contradicts the
415/// scheme whenever an entry was built by hand, and a reader that believes the declaration would
416/// compute codes that never match.
417pub(crate) const fn algorithm_name(
418 kind: &crate::vault::EntryKind,
419 algorithm: crate::otp::Algorithm,
420) -> &'static str {
421 use crate::otp::Algorithm;
422 use crate::vault::EntryKind;
423 match kind {
424 EntryKind::Motp { .. } => "MD5",
425 EntryKind::Yandex { .. } => "SHA256",
426 EntryKind::Steam => "SHA1",
427 EntryKind::Totp { .. } | EntryKind::Hotp { .. } => match algorithm {
428 Algorithm::Sha1 => "SHA1",
429 Algorithm::Sha256 => "SHA256",
430 Algorithm::Sha512 => "SHA512",
431 },
432 }
433}
434
435/// Writes a 128-bit identifier as canonical UUID text.
436///
437/// The reference implementation's entries and groups are identified by UUIDs, so that is the
438/// spelling its files carry. Dashes are written in the canonical places; a reader that strips
439/// them (this library's own does) reads the same value either way.
440pub(crate) fn uuid_text(bytes: &[u8; 16]) -> String {
441 let mut out = String::with_capacity(36);
442 for (index, byte) in bytes.iter().enumerate() {
443 if matches!(index, 4 | 6 | 8 | 10) {
444 out.push('-');
445 }
446 out.push_str(&format!("{byte:02x}"));
447 }
448 out
449}
450
451/// Writes bytes as lower-case hexadecimal.
452pub(crate) fn hex(bytes: &[u8]) -> String {
453 let mut out = String::with_capacity(bytes.len() * 2);
454 for byte in bytes {
455 out.push_str(&format!("{byte:02x}"));
456 }
457 out
458}
459
460/// Writes bytes as standard base64 (RFC 4648 ยง4): `+`, `/`, and `=` padding.
461///
462/// The alphabet is the standard one, not the URL-safe one, because that is what the format
463/// writes for both the encrypted database and the image of its HTML export.
464pub(crate) fn base64(bytes: &[u8]) -> String {
465 const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
466
467 let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
468 for chunk in bytes.chunks(3) {
469 let mut block = [0_u8; 3];
470 block[..chunk.len()].copy_from_slice(chunk);
471 let bits = (u32::from(block[0]) << 16) | (u32::from(block[1]) << 8) | u32::from(block[2]);
472 let letters = [
473 ALPHABET[((bits >> 18) & 0x3F) as usize],
474 ALPHABET[((bits >> 12) & 0x3F) as usize],
475 ALPHABET[((bits >> 6) & 0x3F) as usize],
476 ALPHABET[(bits & 0x3F) as usize],
477 ];
478 let keep = chunk.len() + 1;
479 for (index, letter) in letters.iter().enumerate() {
480 out.push(if index < keep {
481 char::from(*letter)
482 } else {
483 '='
484 });
485 }
486 }
487 out
488}
489
490#[cfg(test)]
491mod tests {
492 use super::*;
493
494 #[test]
495 fn an_identifier_is_written_as_a_canonical_uuid() {
496 let mut bytes = [0_u8; 16];
497 for (index, byte) in bytes.iter_mut().enumerate() {
498 *byte = u8::try_from(index).expect("the index is small");
499 }
500 let text = uuid_text(&bytes);
501 assert_eq!(text, "00010203-0405-0607-0809-0a0b0c0d0e0f");
502 assert_eq!(text.len(), 36);
503 }
504
505 #[test]
506 fn base64_is_the_standard_alphabet_with_padding() {
507 assert_eq!(base64(b""), "");
508 assert_eq!(base64(b"A"), "QQ==");
509 assert_eq!(base64(b"AB"), "QUI=");
510 assert_eq!(base64(b"ABC"), "QUJD");
511 assert_eq!(base64(&[0xFB, 0xFF]), "+/8=");
512 }
513
514 #[test]
515 fn hexadecimal_is_lower_case_and_two_digits_per_byte() {
516 assert_eq!(hex(&[0x00, 0x0F, 0xFF]), "000fff");
517 assert_eq!(hex(&[]), "");
518 }
519
520 /// What each format is called, what it is written with, and what it needs.
521 ///
522 /// A caller builds a file name and asks for a password from these answers, so they are part
523 /// of the interface rather than an internal detail.
524 #[test]
525 fn the_formats_describe_themselves() {
526 let formats = ExportFormat::ALL;
527 let names: Vec<&str> = formats.iter().map(|format| format.name()).collect();
528 assert_eq!(
529 names,
530 vec![
531 "vault",
532 "encrypted vault",
533 "text",
534 "JSON",
535 "encrypted JSON",
536 "HTML"
537 ],
538 "the order is the interface's fixed order"
539 );
540
541 let extensions: Vec<&str> = formats.iter().map(|format| format.extension()).collect();
542 assert_eq!(
543 extensions,
544 vec!["q2fa", "q2fa", "txt", "json", "json", "html"]
545 );
546 let encrypted: Vec<bool> = formats
547 .iter()
548 .map(|format| format.needs_password())
549 .collect();
550 assert_eq!(
551 encrypted,
552 vec![false, true, false, false, true, false],
553 "exactly the two encrypted forms ask for a password"
554 );
555
556 assert_eq!(
557 ExportFormat::Vault.group_carrying(),
558 GroupCarrying::FullTree
559 );
560 assert_eq!(
561 ExportFormat::EncryptedVault.group_carrying(),
562 GroupCarrying::FullTree
563 );
564 assert_eq!(
565 ExportFormat::Json.group_carrying(),
566 GroupCarrying::FlatGroups
567 );
568 assert_eq!(
569 ExportFormat::EncryptedJson.group_carrying(),
570 GroupCarrying::FlatGroups,
571 "the JSON forms keep the group names and memberships, not the nesting"
572 );
573 assert!(!ExportFormat::Json.group_carrying().carries_nesting());
574 assert_eq!(ExportFormat::Text.group_carrying(), GroupCarrying::None);
575 assert_eq!(
576 ExportFormat::Html.group_carrying(),
577 GroupCarrying::None,
578 "a list of URIs and a table have nowhere to put a group"
579 );
580 }
581
582 /// An empty vault exports in every format without an error.
583 ///
584 /// A vault with no entries has nothing to write into a list of URIs, so the text export is
585 /// an empty file - that is a legitimate export, not a failure. The others always have a
586 /// container to write.
587 #[test]
588 fn every_format_writes_an_empty_vault() {
589 let file = VaultFile::create().expect("OS randomness available");
590 for format in ExportFormat::ALL {
591 let password = format.needs_password().then_some(&b"password"[..]);
592 let written = export(&file, format, password)
593 .unwrap_or_else(|error| panic!("{} must write: {error}", format.name()));
594 if format == ExportFormat::Text {
595 assert!(written.is_empty(), "there are no entries to write");
596 } else {
597 assert!(
598 !written.is_empty(),
599 "{} must still produce a container",
600 format.name()
601 );
602 }
603 }
604 }
605
606 /// The two `.q2fa` containers carry the master key and the vault, and can be unpacked again.
607 #[test]
608 fn the_portable_formats_carry_the_key_and_the_vault() {
609 let mut file = VaultFile::create().expect("OS randomness available");
610 file.vault_mut()
611 .insert(
612 crate::vault::Entry::new(
613 "GitHub".into(),
614 Some("GitHub".into()),
615 "alice".into(),
616 crate::otp::OtpSecret::from_base32("JBSWY3DPEHPK3PXP").expect("valid secret"),
617 crate::otp::Algorithm::Sha1,
618 crate::otp::Digits::SIX,
619 crate::vault::EntryKind::Totp { period: 30 },
620 String::new(),
621 1_700_000_000,
622 1_700_000_000,
623 )
624 .expect("valid entry"),
625 )
626 .expect("insertion succeeds");
627
628 let plain =
629 export(&file, ExportFormat::Vault, None).expect("the plain form needs no password");
630 assert_eq!(&plain[..8], crate::portable::PLAIN_MAGIC);
631 let parsed = crate::portable::parse(&plain).expect("it parses");
632 assert_eq!(parsed.kind(), crate::portable::PortableKind::PlainVault);
633 let vault_bytes = parsed.vault().expect("it carries the vault");
634 let reopened = VaultFile::parse(
635 crate::vault::MasterKey::from_bytes(crate::vault::master_key_to_bytes(
636 file.master_key(),
637 ))
638 .expect("the length is correct"),
639 vault_bytes,
640 )
641 .expect("the vault opens with the key the container carried");
642 assert_eq!(reopened.vault().len(), 1);
643
644 let encrypted = export(&file, ExportFormat::EncryptedVault, Some(b"password"))
645 .expect("the encrypted form takes a password");
646 assert_eq!(&encrypted[..8], crate::portable::ENCRYPTED_MAGIC);
647 let parsed = crate::portable::parse(&encrypted).expect("it parses");
648 assert_eq!(parsed.kind(), crate::portable::PortableKind::EncryptedVault);
649 assert!(parsed.vault().is_some());
650
651 assert!(matches!(
652 export(&file, ExportFormat::EncryptedVault, None),
653 Err(Error::ExportPasswordRequired)
654 ));
655 assert!(matches!(
656 export(&file, ExportFormat::EncryptedVault, Some(b"")),
657 Err(Error::ExportPasswordEmpty)
658 ));
659 }
660}