pub mod html;
pub mod json;
pub(crate) mod png;
pub mod text;
use std::collections::HashSet;
use crate::vault::{Entry, EntryId, Group, GroupId, Vault};
use crate::{Error, Result};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExportFormat {
Text,
Json,
EncryptedJson,
Html,
}
impl ExportFormat {
#[must_use]
pub const fn name(self) -> &'static str {
match self {
Self::Text => "text",
Self::Json => "JSON",
Self::EncryptedJson => "encrypted JSON",
Self::Html => "HTML",
}
}
#[must_use]
pub const fn extension(self) -> &'static str {
match self {
Self::Text => "txt",
Self::Json | Self::EncryptedJson => "json",
Self::Html => "html",
}
}
#[must_use]
pub const fn needs_password(self) -> bool {
matches!(self, Self::EncryptedJson)
}
#[must_use]
pub const fn carries_groups(self) -> bool {
matches!(self, Self::Json | Self::EncryptedJson)
}
}
pub fn export(vault: &Vault, format: ExportFormat, password: Option<&[u8]>) -> Result<Vec<u8>> {
let text = match format {
ExportFormat::Text => text::write(vault)?,
ExportFormat::Json => json::write(vault)?,
ExportFormat::Html => html::write(vault)?,
ExportFormat::EncryptedJson => {
let password = password.ok_or(Error::ExportPasswordRequired)?;
if password.is_empty() {
return Err(Error::ExportPasswordEmpty);
}
json::write_encrypted(vault, password)?
}
};
Ok(text.into_bytes())
}
pub(crate) struct Item<'a> {
pub(crate) id: EntryId,
pub(crate) entry: &'a Entry,
pub(crate) group: Option<GroupId>,
pub(crate) position: usize,
}
pub(crate) fn items(vault: &Vault) -> Vec<Item<'_>> {
let mut out: Vec<Item<'_>> = Vec::with_capacity(vault.len());
let mut seen: HashSet<EntryId> = HashSet::with_capacity(vault.len());
for (group_id, _) in vault.groups() {
let named = (!group_id.is_top()).then_some(*group_id);
for (id, entry) in vault.entries_in(group_id) {
if !seen.insert(*id) {
continue;
}
out.push(Item {
id: *id,
entry,
group: named,
position: out.len() + 1,
});
}
}
for (id, entry) in vault.iter() {
if !seen.insert(*id) {
continue;
}
out.push(Item {
id: *id,
entry,
group: None,
position: out.len() + 1,
});
}
out
}
pub(crate) fn named_groups(vault: &Vault) -> Vec<(GroupId, &Group)> {
vault
.groups()
.filter(|(id, _)| !id.is_top())
.map(|(id, group)| (*id, group))
.collect()
}
pub(crate) fn uri_of(entry: &Entry) -> Result<String> {
let Entry {
name,
issuer,
account,
secret,
algorithm,
digits,
kind,
..
} = entry.duplicate()?;
let label = if account.is_empty() { name } else { account };
crate::uri::serialize(&crate::uri::OtpAuth {
kind,
secret,
issuer,
account: label,
algorithm,
digits,
})
}
pub(crate) const fn type_name(kind: &crate::vault::EntryKind) -> &'static str {
use crate::vault::EntryKind;
match kind {
EntryKind::Totp { .. } => "totp",
EntryKind::Hotp { .. } => "hotp",
EntryKind::Steam => "steam",
EntryKind::Motp { .. } => "motp",
EntryKind::Yandex { .. } => "yandex",
}
}
pub(crate) const fn algorithm_name(
kind: &crate::vault::EntryKind,
algorithm: crate::otp::Algorithm,
) -> &'static str {
use crate::otp::Algorithm;
use crate::vault::EntryKind;
match kind {
EntryKind::Motp { .. } => "MD5",
EntryKind::Yandex { .. } => "SHA256",
EntryKind::Steam => "SHA1",
EntryKind::Totp { .. } | EntryKind::Hotp { .. } => match algorithm {
Algorithm::Sha1 => "SHA1",
Algorithm::Sha256 => "SHA256",
Algorithm::Sha512 => "SHA512",
},
}
}
pub(crate) fn uuid_text(bytes: &[u8; 16]) -> String {
let mut out = String::with_capacity(36);
for (index, byte) in bytes.iter().enumerate() {
if matches!(index, 4 | 6 | 8 | 10) {
out.push('-');
}
out.push_str(&format!("{byte:02x}"));
}
out
}
pub(crate) fn hex(bytes: &[u8]) -> String {
let mut out = String::with_capacity(bytes.len() * 2);
for byte in bytes {
out.push_str(&format!("{byte:02x}"));
}
out
}
pub(crate) fn base64(bytes: &[u8]) -> String {
const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
for chunk in bytes.chunks(3) {
let mut block = [0_u8; 3];
block[..chunk.len()].copy_from_slice(chunk);
let bits = (u32::from(block[0]) << 16) | (u32::from(block[1]) << 8) | u32::from(block[2]);
let letters = [
ALPHABET[((bits >> 18) & 0x3F) as usize],
ALPHABET[((bits >> 12) & 0x3F) as usize],
ALPHABET[((bits >> 6) & 0x3F) as usize],
ALPHABET[(bits & 0x3F) as usize],
];
let keep = chunk.len() + 1;
for (index, letter) in letters.iter().enumerate() {
out.push(if index < keep {
char::from(*letter)
} else {
'='
});
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_identifier_is_written_as_a_canonical_uuid() {
let mut bytes = [0_u8; 16];
for (index, byte) in bytes.iter_mut().enumerate() {
*byte = u8::try_from(index).expect("the index is small");
}
let text = uuid_text(&bytes);
assert_eq!(text, "00010203-0405-0607-0809-0a0b0c0d0e0f");
assert_eq!(text.len(), 36);
}
#[test]
fn base64_is_the_standard_alphabet_with_padding() {
assert_eq!(base64(b""), "");
assert_eq!(base64(b"A"), "QQ==");
assert_eq!(base64(b"AB"), "QUI=");
assert_eq!(base64(b"ABC"), "QUJD");
assert_eq!(base64(&[0xFB, 0xFF]), "+/8=");
}
#[test]
fn hexadecimal_is_lower_case_and_two_digits_per_byte() {
assert_eq!(hex(&[0x00, 0x0F, 0xFF]), "000fff");
assert_eq!(hex(&[]), "");
}
#[test]
fn the_formats_describe_themselves() {
let formats = [
ExportFormat::Text,
ExportFormat::Json,
ExportFormat::EncryptedJson,
ExportFormat::Html,
];
let names: Vec<&str> = formats.iter().map(|format| format.name()).collect();
assert_eq!(names, vec!["text", "JSON", "encrypted JSON", "HTML"]);
let extensions: Vec<&str> = formats.iter().map(|format| format.extension()).collect();
assert_eq!(extensions, vec!["txt", "json", "json", "html"]);
assert_eq!(
formats
.iter()
.filter(|format| format.needs_password())
.count(),
1,
"exactly one format is encrypted"
);
assert!(ExportFormat::EncryptedJson.needs_password());
assert!(!ExportFormat::Json.needs_password());
assert!(
ExportFormat::Json.carries_groups() && ExportFormat::EncryptedJson.carries_groups(),
"the two JSON forms carry the groups"
);
assert!(
!ExportFormat::Text.carries_groups() && !ExportFormat::Html.carries_groups(),
"a list of URIs and a table have nowhere to put a group"
);
}
#[test]
fn every_format_writes_an_empty_vault() {
let vault = Vault::new();
for format in [
ExportFormat::Text,
ExportFormat::Json,
ExportFormat::Html,
ExportFormat::EncryptedJson,
] {
let password = format.needs_password().then_some(&b"password"[..]);
let written = export(&vault, format, password)
.unwrap_or_else(|error| panic!("{} must write: {error}", format.name()));
if format == ExportFormat::Text {
assert!(written.is_empty(), "there are no entries to write");
} else {
assert!(
!written.is_empty(),
"{} must still produce a container",
format.name()
);
}
}
}
}