use std::collections::HashMap;
use crate::import::mapping::{RawAlgorithm, RawEntry, RawKind, safe_reason};
use crate::import::{
ImportNote, ImportProblem, ImportReport, ImportedEntry, MAX_ENTRIES, decode_utf8,
drop_duplicates,
};
use crate::vault::Timestamp;
use crate::{Error, Result};
const MAX_CELLS_PER_ROW: usize = 64;
pub fn import(bytes: &[u8], now: Timestamp) -> Result<ImportReport> {
let html = decode_utf8(bytes)?;
let mut table = Table::parse(html)?;
let mut report = ImportReport::default();
let mut seen = 0_usize;
while let Some(row) = table.next_row() {
seen += 1;
if seen > MAX_ENTRIES {
return Err(Error::ImportTooManyEntries {
limit: MAX_ENTRIES,
actual: seen,
});
}
let position = seen;
match read_row(&table.columns, &row, now) {
Ok(Some(read)) => report.entries.push(ImportedEntry {
entry: read.entry,
groups: Vec::new(),
favorite: read.favorite,
position,
notes: read.notes,
}),
Ok(None) => {}
Err(error) => report.problems.push(ImportProblem {
position,
reason: safe_reason(&error),
}),
}
}
summarize(&mut report);
drop_duplicates(&mut report);
Ok(report)
}
struct Read {
entry: crate::vault::Entry,
favorite: bool,
notes: Vec<ImportNote>,
}
struct Cell {
text: String,
png: Option<String>,
}
struct Table {
columns: HashMap<String, usize>,
rows: Vec<Vec<Cell>>,
next: usize,
}
impl Table {
fn parse(html: &str) -> Result<Self> {
let mut columns = HashMap::new();
let mut rows = Vec::new();
let mut heading_seen = false;
for row_html in split_rows(html) {
let (cells, is_heading) = split_cells(row_html);
if cells.is_empty() {
continue;
}
if is_heading && !heading_seen {
for (index, cell) in cells.iter().enumerate() {
let name = cell.text.trim().to_ascii_lowercase();
if !name.is_empty() {
columns.entry(name).or_insert(index);
}
}
heading_seen = true;
continue;
}
if !heading_seen {
continue;
}
rows.push(cells);
}
if !heading_seen {
return Err(Error::ImportMalformed {
reason: "the document has no table heading to read the columns from",
});
}
Ok(Self {
columns,
rows,
next: 0,
})
}
fn next_row(&mut self) -> Option<Vec<Cell>> {
let row = self.rows.get_mut(self.next)?;
self.next += 1;
Some(std::mem::take(row))
}
}
fn read_row(
columns: &HashMap<String, usize>,
row: &[Cell],
now: Timestamp,
) -> Result<Option<Read>> {
let named = |name: &str| -> Option<&Cell> { row.get(*columns.get(name)?) };
let cell = |name: &str| -> Option<&str> {
let value = named(name)?.text.trim();
if value.is_empty() || value == "-" {
None
} else {
Some(value)
}
};
let Some(type_name) = cell("type") else {
return Ok(None);
};
let Some(secret_text) = cell("secret") else {
return Err(malformed("a row has no secret"));
};
let period = period_from_code(named("qr code"));
let kind = match type_name.to_ascii_lowercase().as_str() {
"totp" => RawKind::Totp { period },
"hotp" => RawKind::Hotp {
counter: cell("counter")
.and_then(|text| text.parse::<u64>().ok())
.ok_or(malformed("an HOTP row has no counter"))?,
},
"steam" => RawKind::Steam,
"motp" => RawKind::Motp {
period,
pin: cell("pin").map(str::to_owned),
},
"yandex" => RawKind::Yandex {
period,
pin: cell("pin").map(str::to_owned),
},
_ => return Err(malformed("a row has a type this library does not know")),
};
let secret = secret_as_base32(kind_is_motp(&kind), secret_text)?;
let algorithm = match cell("algo") {
Some(name) => Some(RawAlgorithm::parse(name).ok_or(malformed(
"a row names an algorithm this library does not know",
))?),
None => None,
};
let digits = match cell("digits") {
Some(text) => Some(
text.parse::<u8>()
.map_err(|_| malformed("a row's digit count is not a number"))?,
),
None => None,
};
let favorite = cell("favorite").is_some_and(is_true);
let entry = RawEntry {
name: cell("name").unwrap_or_default().to_owned(),
issuer: cell("issuer").map(str::to_owned),
note: cell("note").unwrap_or_default().to_owned(),
secret,
algorithm,
digits,
kind,
icon: None,
};
let (entry, mut notes) = entry.into_entry(now)?;
if period.is_none()
&& let Some(seconds) = entry.kind.period()
{
let text = std::mem::take(&mut notes);
notes = vec![ImportNote::PeriodAssumed { seconds }];
notes.extend(text);
}
Ok(Some(Read {
entry,
favorite,
notes,
}))
}
fn period_from_code(cell: Option<&Cell>) -> Option<u64> {
let png = decode_base64(cell?.png.as_deref()?)?;
let text = crate::import::qr_text_from_png(&png).ok()?;
crate::uri::parse(&text).ok()?.kind.period()
}
fn embedded_png(fragment: &str) -> Option<String> {
const PREFIX: &str = "data:image/png;base64,";
let start = fragment.to_ascii_lowercase().find(PREFIX)? + PREFIX.len();
let mut payload = String::new();
for character in fragment[start..].chars() {
if character.is_ascii_alphanumeric()
|| matches!(character, '+' | '/' | '=' | '\n' | '\r' | ' ' | '\t')
{
payload.push(character);
} else {
break;
}
}
(!payload.is_empty()).then_some(payload)
}
fn decode_base64(text: &str) -> Option<Vec<u8>> {
const fn 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,
}
}
let mut out = Vec::with_capacity(text.len() / 4 * 3);
let mut chunk = [0_u8; 4];
let mut held = 0_usize;
let mut padding = 0_usize;
for byte in text.bytes() {
if byte.is_ascii_whitespace() {
continue;
}
if byte == b'=' {
padding += 1;
chunk[held] = 0;
} else {
if padding > 0 {
return None;
}
chunk[held] = value(byte)?;
}
held += 1;
if held == 4 {
let bits = (u32::from(chunk[0]) << 18)
| (u32::from(chunk[1]) << 12)
| (u32::from(chunk[2]) << 6)
| u32::from(chunk[3]);
out.push((bits >> 16) as u8);
if padding < 2 {
out.push((bits >> 8) as u8);
}
if padding == 0 {
out.push(bits as u8);
}
held = 0;
}
}
if held != 0 || padding > 2 {
return None;
}
Some(out)
}
fn kind_is_motp(kind: &RawKind) -> bool {
matches!(kind, RawKind::Motp { .. })
}
fn secret_as_base32(is_motp: bool, text: &str) -> Result<String> {
if !is_motp {
return Ok(text.to_owned());
}
let bytes = decode_hex(text).ok_or(malformed("an mOTP row's secret is not hexadecimal"))?;
Ok(crate::base32::encode(&bytes))
}
fn decode_hex(text: &str) -> Option<Vec<u8>> {
let text = text.trim();
if !text.len().is_multiple_of(2) {
return None;
}
let bytes = text.as_bytes();
let mut out = Vec::with_capacity(bytes.len() / 2);
for index in (0..bytes.len()).step_by(2) {
let high = hex_value(*bytes.get(index)?)?;
let low = hex_value(*bytes.get(index + 1)?)?;
out.push((high << 4) | low);
}
Some(out)
}
const fn hex_value(byte: u8) -> Option<u8> {
match byte {
b'0'..=b'9' => Some(byte - b'0'),
b'a'..=b'f' => Some(byte - b'a' + 10),
b'A'..=b'F' => Some(byte - b'A' + 10),
_ => None,
}
}
fn is_true(text: &str) -> bool {
let text = text.trim();
text.eq_ignore_ascii_case("true")
|| text == "1"
|| text.eq_ignore_ascii_case("yes")
|| text.eq_ignore_ascii_case("ja")
|| text.eq_ignore_ascii_case("★")
}
fn summarize(report: &mut ImportReport) {
for entry in &report.entries {
for note in &entry.notes {
match note {
ImportNote::IconDropped => report.dropped.icons += 1,
ImportNote::PeriodAssumed { .. } => report.dropped.periods_assumed += 1,
}
}
}
}
fn split_rows(html: &str) -> Vec<&str> {
let mut rows = Vec::new();
let mut rest = html;
while let Some(start) = find_tag(rest, "tr") {
rest = &rest[start..];
let end = find_tag(rest, "/tr").unwrap_or(rest.len());
rows.push(&rest[..end]);
rest = &rest[end.min(rest.len())..];
if rest.is_empty() {
break;
}
}
rows
}
fn split_cells(row: &str) -> (Vec<Cell>, bool) {
let mut cells = Vec::new();
let mut is_heading = false;
let mut rest = row;
while cells.len() < MAX_CELLS_PER_ROW {
let (start, heading) = match (find_tag(rest, "td"), find_tag(rest, "th")) {
(Some(td), Some(th)) if th < td => (th, true),
(Some(td), None | Some(_)) => (td, false),
(None, Some(th)) => (th, true),
(None, None) => break,
};
is_heading |= heading;
let Some(open_end) = rest[start..].find('>') else {
break;
};
let after = &rest[start + open_end + 1..];
let name = if heading { "/th" } else { "/td" };
let end = find_tag(after, name)
.or_else(|| find_tag(after, "td"))
.or_else(|| find_tag(after, "th"))
.unwrap_or(after.len());
let fragment = &after[..end];
cells.push(Cell {
text: text_of(fragment),
png: embedded_png(fragment),
});
rest = &after[end..];
}
(cells, is_heading)
}
fn text_of(fragment: &str) -> String {
let mut out = String::with_capacity(fragment.len());
let mut chars = fragment.char_indices().peekable();
while let Some((_, character)) = chars.next() {
match character {
'<' => {
for (_, inner) in chars.by_ref() {
if inner == '>' {
break;
}
}
}
'&' => {
let mut entity = String::new();
while let Some((_, inner)) = chars.peek().copied() {
if inner == ';' || entity.len() > 8 {
break;
}
entity.push(inner);
chars.next();
}
if matches!(chars.peek(), Some(&(_, ';'))) {
chars.next();
}
out.push_str(&decode_entity(&entity));
}
other => out.push(other),
}
}
out.trim().to_owned()
}
fn decode_entity(entity: &str) -> String {
match entity {
"amp" => "&".to_owned(),
"lt" => "<".to_owned(),
"gt" => ">".to_owned(),
"quot" => "\"".to_owned(),
"apos" | "#39" => "'".to_owned(),
"nbsp" => " ".to_owned(),
other => format!("&{other};"),
}
}
fn find_tag(haystack: &str, name: &str) -> Option<usize> {
let needle = format!("<{name}");
let lower = haystack.to_ascii_lowercase();
let mut from = 0;
while let Some(offset) = lower[from..].find(&needle) {
let start = from + offset;
let after = start + needle.len();
let boundary = lower[after..].chars().next();
if matches!(boundary, Some(c) if c.is_ascii_alphanumeric() || c == '-') {
from = after;
continue;
}
return Some(start);
}
None
}
const fn malformed(reason: &'static str) -> Error {
Error::ImportMalformed { reason }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::import::ImportFormat;
use crate::vault::EntryKind;
fn sample() -> String {
let pin = "1234";
format!(
r#"<!DOCTYPE html>
<html><head><title>Vault</title></head>
<body>
<table>
<tr><th>Issuer</th><th>Name</th><th>Type</th><th>QR Code</th><th>UUID</th><th>Note</th><th>Favorite</th><th>Algo</th><th>Digits</th><th>Secret</th><th>Counter</th><th>PIN</th></tr>
<tr><td>GitHub</td><td>alice</td><td>totp</td><td class='qr'><img src="data:image/png;base64,AAAA"></td><td>1111</td><td>work & play</td><td>1</td><td>SHA1</td><td>6</td><td>JBSWY3DPEHPK3PXP</td><td>-</td><td>-</td></tr>
<tr><td>Bank</td><td>bob</td><td>hotp</td><td class='qr'></td><td>2222</td><td></td><td>0</td><td>SHA256</td><td>8</td><td>JBSWY3DPEHPK3PXP</td><td>7</td><td>-</td></tr>
<tr><td></td><td>Steam</td><td>steam</td><td class='qr'></td><td>3333</td><td></td><td></td><td>SHA1</td><td>5</td><td>JBSWY3DPEHPK3PXP</td><td>-</td><td>-</td></tr>
<tr><td>Example</td><td>mOTP</td><td>motp</td><td class='qr'></td><td>4444</td><td></td><td></td><td>MD5</td><td>6</td><td>deadbeef</td><td>-</td><td>1234</td></tr>
<tr><td></td><td>Yandex</td><td>yandex</td><td class='qr'></td><td>5555</td><td></td><td></td><td>SHA256</td><td>8</td><td>LA2V6KMCGYMWWVEW64RNP3JA3I</td><td>-</td><td>{pin}</td></tr>
</table>
</body>
<style>body {{ font-family: sans-serif; }}</style>
</html>
"#
)
}
fn import_sample(text: &str) -> ImportReport {
import(text.as_bytes(), 1_700_000_000).expect("the sample is well formed")
}
#[test]
fn every_scheme_in_the_table_is_read() {
let report = import_sample(&sample());
assert!(report.problems.is_empty(), "actual {:?}", report.problems);
assert_eq!(report.entries.len(), 5);
let kinds: Vec<&EntryKind> = report
.entries
.iter()
.map(|entry| &entry.entry.kind)
.collect();
assert_eq!(kinds[0], &EntryKind::Totp { period: 30 });
assert_eq!(kinds[1], &EntryKind::Hotp { counter: 7 });
assert_eq!(kinds[2], &EntryKind::Steam);
assert_eq!(
kinds[3],
&EntryKind::Motp {
period: 10,
pin: Some(crate::motp::MotpPin::new("1234".to_owned()))
},
"the hexadecimal secret is converted and the PIN is read"
);
assert_eq!(
kinds[4],
&EntryKind::Yandex {
period: 30,
pin: Some(crate::yaotp::YandexPin::new("1234".to_owned()))
}
);
assert_eq!(report.entries[0].entry.issuer.as_deref(), Some("GitHub"));
assert_eq!(
report.entries[0].entry.note, "work & play",
"an entity in a note is decoded"
);
assert_eq!(report.entries[2].entry.issuer, None);
assert_eq!(report.entries[2].entry.name, "Steam");
assert!(!report.entries[0].entry.note.is_empty());
}
#[test]
fn a_step_that_the_format_does_not_carry_is_reported_as_assumed() {
let report = import_sample(&sample());
for (index, expected) in [(0_usize, 30_u64), (3, 10), (4, 30)] {
assert!(
report.entries[index]
.notes
.contains(&ImportNote::PeriodAssumed { seconds: expected }),
"entry {index} must say its step was assumed"
);
}
assert_eq!(
report.dropped.periods_assumed, 3,
"the schemes with a step are counted, and only those"
);
assert!(
report.entries[1].notes.is_empty(),
"HOTP has no step to assume"
);
assert!(report.entries[2].notes.is_empty(), "nor has Steam Guard");
}
#[test]
fn the_secret_comes_from_the_column_and_only_the_step_from_the_code() {
use qualia_transport::qr::{self, EcLevel};
const IN_THE_CODE: &str = "KRSXG5CTMVRXEZLU";
const IN_THE_COLUMN: &str = "JBSWY3DPEHPK3PXP";
let uri = format!("otpauth://totp/Elsewhere:someone?secret={IN_THE_CODE}&period=90");
let symbol = qr::encode_text(&uri, EcLevel::Low, 40).expect("the URI fits a code");
let drawn = symbol.render(4, 4).expect("the render geometry is legal");
let document = format!(
"<table>\
<tr><th>Name</th><th>Type</th><th>QR Code</th><th>Algo</th><th>Digits</th><th>Secret</th></tr>\
<tr><td>GitHub</td><td>totp</td>\
<td><img src=\"data:image/png;base64,{}\"></td>\
<td>SHA1</td><td>6</td><td>{IN_THE_COLUMN}</td></tr>\
</table>",
crate::export::base64(&crate::export::png::encode(&drawn))
);
let report = import_sample(&document);
assert!(report.problems.is_empty(), "actual {:?}", report.problems);
assert_eq!(report.entries.len(), 1);
assert_eq!(
report.entries[0].entry.secret.to_base32(),
IN_THE_COLUMN,
"the secret is the column's"
);
assert_eq!(
report.entries[0].entry.kind,
EntryKind::Totp { period: 90 },
"the step is the code's"
);
assert_eq!(
report.dropped.periods_assumed, 0,
"a step that was read is not an assumption"
);
}
#[test]
fn an_image_that_carries_no_code_leaves_the_step_assumed() {
let document = "<table>\
<tr><th>Name</th><th>Type</th><th>QR Code</th><th>Algo</th><th>Digits</th><th>Secret</th></tr>\
<tr><td>GitHub</td><td>totp</td>\
<td><img src=\"data:image/png;base64,iVBORw0KGgo=\"></td>\
<td>SHA1</td><td>6</td><td>JBSWY3DPEHPK3PXP</td></tr>\
</table>";
let report = import_sample(document);
assert!(report.problems.is_empty(), "the row itself is fine");
assert_eq!(report.entries.len(), 1);
assert_eq!(report.entries[0].entry.kind, EntryKind::Totp { period: 30 });
assert!(
report.entries[0]
.notes
.contains(&ImportNote::PeriodAssumed { seconds: 30 }),
"the default has to be reported when the code could not be read"
);
assert_eq!(report.dropped.periods_assumed, 1);
}
#[test]
fn the_image_payload_is_read_through_its_line_breaks() {
assert_eq!(
super::decode_base64("QUJD\nREVG\n").expect("valid"),
b"ABCDEF".to_vec()
);
assert_eq!(
super::decode_base64("").expect("empty is valid"),
Vec::<u8>::new()
);
for bad in ["Q", "Q!==", "QUJD=QUJD", "QQ==="] {
assert!(
super::decode_base64(bad).is_none(),
"{bad} is not base64 and must be refused"
);
}
assert_eq!(
super::embedded_png("<img src=\"data:image/png;base64,QUJD\nREVG\">")
.expect("the payload is there"),
"QUJD\nREVG"
);
assert!(
super::embedded_png("<img src=\"data:image/jpeg;base64,QUJD\">").is_none(),
"only a PNG is looked at"
);
}
#[test]
fn the_columns_are_found_by_their_heading() {
let text = "<table>\
<tr><th>Secret</th><th>Type</th><th>Name</th><th>Digits</th><th>Algo</th></tr>\
<tr><td>JBSWY3DPEHPK3PXP</td><td>totp</td><td>reordered</td><td>8</td><td>SHA256</td></tr>\
</table>";
let report = import_sample(text);
assert!(report.problems.is_empty(), "actual {:?}", report.problems);
assert_eq!(report.entries.len(), 1);
assert_eq!(report.entries[0].entry.name, "reordered");
assert_eq!(report.entries[0].entry.digits, crate::otp::Digits::EIGHT);
assert_eq!(
report.entries[0].entry.algorithm,
crate::otp::Algorithm::Sha256
);
assert_eq!(
report.entries[0].entry.secret.to_base32(),
"JBSWY3DPEHPK3PXP",
"the secret is found by its heading, not by its position"
);
assert_eq!(
report.entries[0].entry.kind,
EntryKind::Totp { period: 30 },
"a table with no step column still gets the default, and is told so"
);
assert_eq!(report.dropped.periods_assumed, 1);
}
#[test]
fn a_document_without_a_heading_is_refused() {
let result = import(b"<html><body>no table here</body></html>", 0);
assert!(
matches!(result, Err(Error::ImportMalformed { .. })),
"actual {result:?}"
);
}
#[test]
fn one_bad_row_does_not_end_the_import() {
let text = sample().replace(
"<td>SHA1</td><td>6</td><td>JBSWY3DPEHPK3PXP</td>",
"<td>SHA1</td><td>10</td><td>JBSWY3DPEHPK3PXP</td>",
);
let report = import_sample(&text);
assert_eq!(report.entries.len(), 4);
assert_eq!(report.problems.len(), 1);
assert_eq!(report.problems[0].position, 1);
assert!(
report.problems[0].reason.contains("31 bits"),
"actual {}",
report.problems[0].reason
);
}
#[test]
fn a_row_that_is_not_an_entry_is_skipped_quietly() {
let text = sample().replace("<table>", "<table>\n<tr><td>spacer</td><td></td></tr>");
let report = import_sample(&text);
assert!(report.problems.is_empty(), "actual {:?}", report.problems);
assert_eq!(report.entries.len(), 5);
}
#[test]
fn a_secret_never_reaches_the_problem_text() {
let text = sample().replace("<td>deadbeef</td>", "<td>NOT-HEX-AT-ALL</td>");
let report = import_sample(&text);
assert_eq!(report.problems.len(), 1);
let reason = &report.problems[0].reason;
assert!(
!reason.contains("NOT-HEX") && !reason.contains("deadbeef"),
"the reason must not quote the row: {reason}"
);
}
#[test]
fn entity_decoding_covers_what_an_exporter_writes() {
assert_eq!(text_of("a & b"), "a & b");
assert_eq!(text_of("<b>"), "<b>");
assert_eq!(text_of(""q""), "\"q\"");
assert_eq!(text_of("it's"), "it's");
assert_eq!(text_of("<b>bold</b>"), "bold");
assert_eq!(
text_of("&unknown;"),
"&unknown;",
"an entity this reader does not know keeps its text"
);
assert_eq!(text_of("unterminated <b"), "unterminated");
}
#[test]
fn the_format_is_detected_from_the_document() {
assert_eq!(
ImportFormat::detect(sample().as_bytes()),
Some(ImportFormat::Html)
);
}
}