pub mod apply;
mod encrypted;
pub mod html;
pub mod json;
mod mapping;
mod png;
pub use png::{PngError, qr_text_from_png};
pub mod text;
use crate::vault::Timestamp;
use crate::{Error, Result};
pub const MAX_EXPORT_BYTES: usize = 8 * 1024 * 1024;
pub const MAX_ENTRIES: usize = 10_000;
pub const MAX_JSON_DEPTH: usize = 32;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ImportFormat {
Json,
Html,
Text,
}
impl ImportFormat {
#[must_use]
pub const fn name(self) -> &'static str {
match self {
Self::Json => "JSON",
Self::Html => "HTML",
Self::Text => "text",
}
}
#[must_use]
pub fn detect(bytes: &[u8]) -> Option<Self> {
let text = core::str::from_utf8(bytes).ok()?;
let trimmed = text.trim_start_matches(['\u{feff}', ' ', '\t', '\r', '\n']);
if trimmed.starts_with('{') {
return Some(Self::Json);
}
if trimmed.starts_with("otpauth://") || trimmed.starts_with("motp:") {
return Some(Self::Text);
}
let lower = trimmed.to_ascii_lowercase();
if lower.starts_with("<!doctype html")
|| lower.contains("<html")
|| lower.contains("<table")
{
return Some(Self::Html);
}
None
}
}
#[derive(Debug)]
pub struct ImportedEntry {
pub entry: crate::vault::Entry,
pub groups: Vec<crate::vault::GroupId>,
pub favorite: bool,
pub position: usize,
pub notes: Vec<ImportNote>,
}
impl ImportedEntry {
#[must_use]
pub fn has_notes(&self) -> bool {
!self.notes.is_empty()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ImportedGroup {
pub id: crate::vault::GroupId,
pub name: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ImportNote {
IconDropped,
PeriodAssumed {
seconds: u64,
},
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct DroppedSummary {
pub icons: usize,
pub periods_assumed: usize,
pub duplicates: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ImportProblem {
pub position: usize,
pub reason: String,
}
#[derive(Debug, Default)]
pub struct ImportReport {
pub entries: Vec<ImportedEntry>,
pub groups: Vec<ImportedGroup>,
pub problems: Vec<ImportProblem>,
pub dropped: DroppedSummary,
}
impl ImportReport {
#[must_use]
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub(crate) fn check_size(bytes: &[u8]) -> Result<()> {
if bytes.len() > MAX_EXPORT_BYTES {
return Err(Error::ImportFileTooLarge {
limit: MAX_EXPORT_BYTES,
actual: bytes.len(),
});
}
Ok(())
}
}
pub fn import(
bytes: &[u8],
format: ImportFormat,
now: Timestamp,
password: Option<&[u8]>,
) -> Result<ImportReport> {
ImportReport::check_size(bytes)?;
match format {
ImportFormat::Json => json::import(bytes, now, password),
ImportFormat::Text => text::import(bytes, now),
ImportFormat::Html => html::import(bytes, now),
}
}
pub(crate) fn drop_duplicates(report: &mut ImportReport) {
let mut buckets: std::collections::HashMap<String, Vec<usize>> =
std::collections::HashMap::new();
let mut kept: Vec<ImportedEntry> = Vec::with_capacity(report.entries.len());
for entry in report.entries.drain(..) {
let bucket = buckets.entry(format!("{:?}", entry.entry)).or_default();
if bucket.iter().any(|index| kept[*index].entry == entry.entry) {
report.dropped.duplicates += 1;
continue;
}
bucket.push(kept.len());
kept.push(entry);
}
report.entries = kept;
}
pub(crate) fn decode_base64(text: &str) -> Option<Vec<u8>> {
let bytes = text.as_bytes();
let chunks = bytes.len() / 4;
if !bytes.len().is_multiple_of(4) {
return None;
}
let mut out = Vec::with_capacity(chunks * 3);
for (index_of_chunk, chunk) in bytes.chunks(4).enumerate() {
let mut values = [0_u8; 4];
let mut padding = 0_usize;
for (index, byte) in chunk.iter().enumerate() {
if *byte == b'=' {
padding += 1;
continue;
}
if padding > 0 {
return None;
}
values[index] = base64_value(*byte)?;
}
if padding > 2 || (padding > 0 && index_of_chunk + 1 != chunks) {
return None;
}
let bits = (u32::from(values[0]) << 18)
| (u32::from(values[1]) << 12)
| (u32::from(values[2]) << 6)
| u32::from(values[3]);
out.push((bits >> 16) as u8);
if padding < 2 {
out.push((bits >> 8) as u8);
}
if padding == 0 {
out.push(bits as u8);
}
}
Some(out)
}
const fn base64_value(byte: u8) -> Option<u8> {
match byte {
b'A'..=b'Z' => Some(byte - b'A'),
b'a'..=b'z' => Some(byte - b'a' + 26),
b'0'..=b'9' => Some(byte - b'0' + 52),
b'+' => Some(62),
b'/' => Some(63),
_ => None,
}
}
pub(crate) fn decode_utf8(bytes: &[u8]) -> Result<&str> {
core::str::from_utf8(bytes).map_err(|_| Error::ImportMalformed {
reason: "the file is not valid UTF-8",
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detection_reads_the_content_not_the_name() {
assert_eq!(
ImportFormat::detect(br#"{"version":1}"#),
Some(ImportFormat::Json)
);
assert_eq!(
ImportFormat::detect(b"otpauth://totp/a?secret=JBSWY3DPEHPK3PXP"),
Some(ImportFormat::Text)
);
assert_eq!(
ImportFormat::detect(b"motp:/a?secret=deadbeef"),
Some(ImportFormat::Text)
);
assert_eq!(
ImportFormat::detect(b"<!DOCTYPE html>\n<html><body></body></html>"),
Some(ImportFormat::Html)
);
assert_eq!(ImportFormat::detect(b"nonsense"), None);
assert_eq!(ImportFormat::detect(&[0xFF, 0xFE]), None);
}
#[test]
fn detection_skips_a_byte_order_mark_and_leading_space() {
assert_eq!(
ImportFormat::detect("\u{feff} \r\n otpauth://totp/a?secret=AA".as_bytes()),
Some(ImportFormat::Text)
);
assert_eq!(
ImportFormat::detect(b"\n\n {\"version\":1}"),
Some(ImportFormat::Json)
);
}
#[test]
fn an_oversized_export_is_refused_before_it_is_read() {
let bytes = vec![0_u8; MAX_EXPORT_BYTES + 1];
let result = import(&bytes, ImportFormat::Json, 0, None);
assert!(
matches!(result, Err(Error::ImportFileTooLarge { .. })),
"actual {result:?}"
);
}
#[test]
fn an_entry_repeated_in_the_export_is_kept_once() {
let text = "otpauth://totp/a?secret=JBSWY3DPEHPK3PXP\n\
otpauth://totp/a?secret=JBSWY3DPEHPK3PXP\n\
otpauth://totp/a?secret=JBSWY3DPEHPK3PXP&digits=8\n";
let report = import(text.as_bytes(), ImportFormat::Text, 1_700_000_000, None)
.expect("the text is valid UTF-8");
assert_eq!(
report.entries.len(),
2,
"the exact repeat is dropped and the differing entry is kept"
);
assert_eq!(report.dropped.duplicates, 1);
assert_eq!(report.entries[1].entry.digits, crate::otp::Digits::EIGHT);
}
}