Skip to main content

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}