use qualia_transport::image::GrayImage;
use qualia_transport::qr::{self, EcLevel};
use crate::Error;
use crate::Result;
use crate::export::{algorithm_name, base64, hex, items, type_name, uuid_text};
use crate::vault::{Entry, EntryKind, Vault};
const QR_IMAGE_SIDE: u32 = 256;
const QR_QUIET_ZONE: u16 = 4;
const QR_EC_LEVEL: EcLevel = EcLevel::Low;
const QR_MAX_VERSION: u8 = 40;
const BASE64_LINE_WIDTH: usize = 76;
const COLUMNS: [&str; 12] = [
"Issuer", "Name", "Type", "QR Code", "UUID", "Note", "Favorite", "Algo", "Digits", "Secret",
"Counter", "PIN",
];
const EMPTY_CELL: &str = "-";
pub fn write(vault: &Vault) -> Result<String> {
let mut out = String::with_capacity(1024 + vault.len() * 2048);
out.push_str("<!DOCTYPE html>\n<html>\n<head>\n<meta charset=\"utf-8\">\n");
out.push_str("<title>Qualia2FA export</title>\n");
out.push_str("<style>\n");
out.push_str("body { font-family: sans-serif; }\n");
out.push_str("table { border-collapse: collapse; }\n");
out.push_str("th, td { border: 1px solid #999; padding: 4px 8px; text-align: left; }\n");
out.push_str("td.qr img { width: 128px; height: 128px; }\n");
out.push_str("</style>\n</head>\n<body>\n<table>\n");
out.push_str("<tr>");
for column in COLUMNS {
out.push_str("<th>");
escape_into(&mut out, column);
out.push_str("</th>");
}
out.push_str("</tr>\n");
for item in items(vault) {
let entry = item.entry;
let uri = crate::export::uri_of(entry)?;
out.push_str("<tr>");
cell(&mut out, entry.issuer.as_deref().unwrap_or_default());
cell(&mut out, &entry.name);
type_cell(&mut out, &entry.kind);
out.push_str("<td class='qr'><img src=\"");
out.push_str(&qr_data_url(&uri, item.position)?);
out.push_str("\"></td>");
cell(&mut out, &uuid_text(item.id.as_bytes()));
cell(&mut out, &entry.note);
cell(&mut out, "false");
cell(&mut out, algorithm_name(&entry.kind, entry.algorithm));
cell(&mut out, &entry.digits.count().to_string());
cell(&mut out, &secret_text(entry));
match &entry.kind {
EntryKind::Hotp { counter } => cell(&mut out, &counter.to_string()),
_ => not_applicable(&mut out),
}
match &entry.kind {
EntryKind::Motp { pin: Some(pin), .. } => cell(&mut out, pin.expose()),
EntryKind::Yandex { pin: Some(pin), .. } => cell(&mut out, pin.expose()),
_ => not_applicable(&mut out),
}
out.push_str("</tr>\n");
}
out.push_str("</table>\n</body>\n</html>\n");
Ok(out)
}
fn secret_text(entry: &Entry) -> String {
match entry.kind {
EntryKind::Motp { .. } => hex(entry.secret.expose()),
_ => entry.secret.to_base32(),
}
}
fn cell(out: &mut String, value: &str) {
out.push_str("<td>");
escape_into(out, value);
out.push_str("</td>");
}
fn not_applicable(out: &mut String) {
out.push_str("<td>");
out.push_str(EMPTY_CELL);
out.push_str("</td>");
}
fn type_cell(out: &mut String, kind: &EntryKind) {
cell(out, &type_name(kind).to_ascii_uppercase());
}
fn escape_into(out: &mut String, value: &str) {
for character in value.chars() {
match character {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
other => out.push(other),
}
}
}
fn qr_data_url(uri: &str, position: usize) -> Result<String> {
let symbol = qr::encode_text(uri, QR_EC_LEVEL, QR_MAX_VERSION)
.map_err(|_| Error::ExportQrTooLong { position })?;
let modules = u32::from(symbol.size()) + 2 * u32::from(QR_QUIET_ZONE);
#[expect(
clippy::cast_possible_truncation,
reason = "the side in modules is at most 185, so the quotient is at most 256"
)]
let scale = (QR_IMAGE_SIDE / modules).max(1) as u16;
let drawn = symbol
.render(scale, QR_QUIET_ZONE)
.map_err(|_| refused("QR render geometry"))?;
let canvas = centre(&drawn)?;
Ok(format!(
"data:image/png;base64,{}",
wrap(&base64(&crate::export::png::encode(&canvas)))
))
}
fn centre(drawn: &GrayImage) -> Result<GrayImage> {
let side = drawn.width().min(drawn.height());
let offset = (QR_IMAGE_SIDE - side.min(QR_IMAGE_SIDE)) / 2;
let mut pixels = vec![u8::MAX; (QR_IMAGE_SIDE * QR_IMAGE_SIDE) as usize];
for y in 0..side {
for x in 0..side {
let Some(value) = drawn.pixel(x, y) else {
continue;
};
let target = (y + offset) * QR_IMAGE_SIDE + (x + offset);
if let Some(pixel) = pixels.get_mut(target as usize) {
*pixel = value;
}
}
}
GrayImage::from_luma8(QR_IMAGE_SIDE, QR_IMAGE_SIDE, pixels)
.map_err(|_| refused("QR image size"))
}
fn wrap(text: &str) -> String {
let mut out = String::with_capacity(text.len() + text.len() / BASE64_LINE_WIDTH + 1);
for (index, character) in text.chars().enumerate() {
if index > 0 && index % BASE64_LINE_WIDTH == 0 {
out.push('\n');
}
out.push(character);
}
out
}
const fn refused(parameter: &'static str) -> Error {
Error::ExportParameterRefused { parameter }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::import::{ImportFormat, import};
use crate::vault::EntryId;
fn entry(uri: &str, now: u64) -> Entry {
let parsed = crate::uri::parse(uri).expect("the uri is valid");
crate::vault::entry_from_parsed_uri(parsed, now).expect("the entry is valid")
}
const URIS: [&str; 5] = [
"otpauth://totp/GitHub:alice?secret=JBSWY3DPEHPK3PXP&issuer=GitHub&period=60",
"otpauth://hotp/Bank:bob?secret=JBSWY3DPEHPK3PXP&issuer=Bank&counter=7&digits=8",
"otpauth://steam/alice?secret=JBSWY3DPEHPK3PXP",
"motp:/Example:alice?secret=deadbeef&issuer=Example&pin=GEZDGNBVGY3Q",
"otpauth://yaotp/alice?secret=LA2V6KMCGYMWWVEW64RNP3JA3I&pin=GEZDGNBVGY3Q",
];
fn vault() -> (Vault, Vec<EntryId>) {
let mut vault = Vault::new();
let ids = URIS
.iter()
.map(|uri| {
vault
.insert(entry(uri, 1_600_000_000))
.expect("the vault accepts it")
})
.collect();
(vault, ids)
}
#[test]
fn an_export_round_trips_through_the_reader() {
let (vault, _) = vault();
let text = write(&vault).expect("the export is written");
let report = import(text.as_bytes(), ImportFormat::Html, 1_700_000_000, None)
.unwrap_or_else(|error| panic!("the export must read back: {error}"));
assert!(report.problems.is_empty(), "actual {:?}", report.problems);
assert_eq!(report.entries.len(), URIS.len());
for (index, uri) in URIS.iter().enumerate() {
let original = entry(uri, 1_600_000_000);
let read = &report.entries[index].entry;
assert_eq!(
read.kind, original.kind,
"entry {index} (the step comes from the code)"
);
assert_eq!(read.name, original.name, "entry {index}");
assert_eq!(read.issuer, original.issuer, "entry {index}");
assert_eq!(read.digits, original.digits, "entry {index}");
assert_eq!(
read.secret.to_base32(),
original.secret.to_base32(),
"entry {index}"
);
}
assert_eq!(
report.dropped.periods_assumed, 0,
"every step was in the code, so none had to be assumed"
);
}
#[test]
fn a_step_that_is_not_the_default_survives() {
let mut vault = Vault::new();
vault
.insert(entry(
"otpauth://totp/GitHub:alice?secret=JBSWY3DPEHPK3PXP&period=90",
1_600_000_000,
))
.expect("the vault is empty");
let text = write(&vault).expect("the export is written");
let report = import(text.as_bytes(), ImportFormat::Html, 0, None).expect("it reads back");
assert_eq!(report.entries[0].entry.kind, EntryKind::Totp { period: 90 });
assert_eq!(
report.dropped.periods_assumed, 0,
"the step was read, not assumed"
);
}
#[test]
fn the_table_has_the_columns_of_the_format() {
let (vault, ids) = vault();
let text = write(&vault).expect("the export is written");
let heading = text
.lines()
.find(|line| line.starts_with("<tr><th>"))
.expect("there is a heading row");
let names: Vec<&str> = heading
.split("<th>")
.skip(1)
.map(|cell| cell.split("</th>").next().unwrap_or_default())
.collect();
assert_eq!(names, COLUMNS.to_vec());
assert!(
text.contains(&format!("<td>{}</td>", uuid_text(ids[0].as_bytes()))),
"the first entry's UUID must be in the table"
);
assert_eq!(
text.matches("data:image/png;base64,").count(),
URIS.len(),
"every entry gets a code, including the ones whose scheme has no step here"
);
}
#[test]
fn the_drawn_code_is_a_wrapped_256_pixel_png() {
let mut vault = Vault::new();
vault.insert(entry(URIS[0], 0)).expect("the vault is empty");
let text = write(&vault).expect("the export is written");
let start = text.find("base64,").expect("there is an image") + 7;
let end = start + text[start..].find('"').expect("the attribute ends");
let encoded = &text[start..end];
let lines: Vec<&str> = encoded.split('\n').collect();
assert!(
lines.len() > 1,
"the base64 of an image this size is wrapped"
);
for line in &lines[..lines.len() - 1] {
assert_eq!(line.len(), BASE64_LINE_WIDTH);
}
assert!(lines[lines.len() - 1].len() <= BASE64_LINE_WIDTH);
let png = decode_base64_for_test(&lines.join(""));
assert_eq!(
&png[..8],
&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A],
"the payload is a PNG"
);
assert_eq!(
u32::from_be_bytes(png[16..20].try_into().expect("four bytes")),
256,
"the width the format specifies"
);
assert_eq!(
u32::from_be_bytes(png[20..24].try_into().expect("four bytes")),
256,
"and the height"
);
}
#[test]
fn a_note_is_escaped() {
let mut vault = Vault::new();
let mut built = entry(URIS[0], 0);
built.note = "<b>not bold</b> & \"quoted\"".to_owned();
built.name = "名字".to_owned();
built.issuer = None;
vault.insert(built).expect("the vault is empty");
let text = write(&vault).expect("the export is written");
assert!(
text.contains("<b>not bold</b> & "quoted""),
"the note must be escaped: {text}"
);
let report = import(text.as_bytes(), ImportFormat::Html, 0, None).expect("it reads back");
assert_eq!(report.entries[0].entry.note, "<b>not bold</b> & \"quoted\"");
assert_eq!(report.entries[0].entry.name, "名字");
}
#[test]
fn an_absent_value_is_empty_and_a_field_that_does_not_apply_is_a_dash() {
let mut vault = Vault::new();
let mut built = entry(URIS[1], 0);
built.issuer = None;
built.note = String::new();
vault.insert(built).expect("the vault is empty");
let text = write(&vault).expect("the export is written");
let start = text.find("<tr><td>").expect("there is a data row");
let end = start + text[start..].find("</tr>").expect("the row ends");
let row = &text[start..end];
assert!(
row.starts_with("<tr><td></td>"),
"an entry with no issuer leaves the cell empty: {row}"
);
assert!(row.contains("<td>7</td>"), "the counter is written: {row}");
assert_eq!(
row.matches("<td>-</td>").count(),
1,
"only the PIN, which does not apply to an HOTP entry, is a dash: {row}"
);
assert!(
row.contains("<td>HOTP</td>"),
"this format spells the type in upper case: {row}"
);
assert!(
row.contains("<td>false</td>"),
"the favourite column is written as the word: {row}"
);
let report = import(text.as_bytes(), ImportFormat::Html, 0, None).expect("it reads back");
assert_eq!(report.entries.len(), 1);
assert_eq!(report.entries[0].entry.issuer, None);
assert!(report.entries[0].entry.note.is_empty());
}
fn decode_base64_for_test(text: &str) -> Vec<u8> {
const ALPHABET: &[u8; 64] =
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = Vec::new();
let mut bits = 0_u32;
let mut held = 0_u32;
for byte in text.bytes() {
if byte == b'=' {
break;
}
let value = ALPHABET
.iter()
.position(|letter| *letter == byte)
.expect("a valid base64 character") as u32;
bits = (bits << 6) | value;
held += 6;
if held >= 8 {
held -= 8;
out.push(((bits >> held) & 0xFF) as u8);
}
}
out
}
}