Compare commits
2 Commits
feature/cl
...
feature/cl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e791e4853 | ||
|
|
bfec232f11 |
@@ -11,7 +11,7 @@ use anyhow::{Context, Result};
|
|||||||
|
|
||||||
use crate::AddKind;
|
use crate::AddKind;
|
||||||
use crate::parse::{base32_decode_lenient, guess_mime, parse_month_year};
|
use crate::parse::{base32_decode_lenient, guess_mime, parse_month_year};
|
||||||
use crate::prompt::{prompt, prompt_optional, prompt_secret};
|
use crate::prompt::{prompt, prompt_optional, prompt_or_flag, prompt_or_flag_optional, prompt_secret};
|
||||||
|
|
||||||
pub fn cmd_add(kind: AddKind) -> Result<()> {
|
pub fn cmd_add(kind: AddKind) -> Result<()> {
|
||||||
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||||
@@ -69,9 +69,9 @@ fn build_login_item(
|
|||||||
use relicario_core::{Item, ItemCore};
|
use relicario_core::{Item, ItemCore};
|
||||||
use zeroize::Zeroizing;
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
let title = title.map(Ok).unwrap_or_else(|| prompt("Title"))?;
|
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
|
||||||
let username = username.or_else(|| prompt_optional("Username").ok().flatten());
|
let username = prompt_or_flag_optional(username, "Username", |s| Ok(s.to_string()))?;
|
||||||
let url = url.or_else(|| prompt_optional("URL").ok().flatten());
|
let url = prompt_or_flag_optional(url, "URL", |s| Ok(s.to_string()))?;
|
||||||
let parsed_url = match url {
|
let parsed_url = match url {
|
||||||
Some(s) => Some(url::Url::parse(&s).with_context(|| format!("invalid URL: {s}"))?),
|
Some(s) => Some(url::Url::parse(&s).with_context(|| format!("invalid URL: {s}"))?),
|
||||||
None => None,
|
None => None,
|
||||||
@@ -115,7 +115,7 @@ fn build_secure_note_item(
|
|||||||
use relicario_core::{Item, ItemCore};
|
use relicario_core::{Item, ItemCore};
|
||||||
use zeroize::Zeroizing;
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
let title = title.map(Ok).unwrap_or_else(|| prompt("Title"))?;
|
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
|
||||||
let body = if body_prompt {
|
let body = if body_prompt {
|
||||||
eprintln!("Enter note body; end with Ctrl-D on a blank line:");
|
eprintln!("Enter note body; end with Ctrl-D on a blank line:");
|
||||||
let mut s = String::new();
|
let mut s = String::new();
|
||||||
@@ -144,7 +144,7 @@ fn build_identity_item(
|
|||||||
use relicario_core::item_types::IdentityCore;
|
use relicario_core::item_types::IdentityCore;
|
||||||
use relicario_core::{Item, ItemCore};
|
use relicario_core::{Item, ItemCore};
|
||||||
|
|
||||||
let title = title.map(Ok).unwrap_or_else(|| prompt("Title"))?;
|
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
|
||||||
let dob = match date_of_birth {
|
let dob = match date_of_birth {
|
||||||
Some(s) => Some(chrono::NaiveDate::parse_from_str(&s, "%Y-%m-%d")
|
Some(s) => Some(chrono::NaiveDate::parse_from_str(&s, "%Y-%m-%d")
|
||||||
.with_context(|| format!("invalid date {s} (expected YYYY-MM-DD)"))?),
|
.with_context(|| format!("invalid date {s} (expected YYYY-MM-DD)"))?),
|
||||||
@@ -170,7 +170,7 @@ fn build_card_item(
|
|||||||
use relicario_core::{Item, ItemCore};
|
use relicario_core::{Item, ItemCore};
|
||||||
use zeroize::Zeroizing;
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
let title = title.map(Ok).unwrap_or_else(|| prompt("Title"))?;
|
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
|
||||||
let number = Zeroizing::new(prompt_secret("Card number: ")?);
|
let number = Zeroizing::new(prompt_secret("Card number: ")?);
|
||||||
let cvv = Zeroizing::new(prompt_secret("CVV (blank to skip): ")?);
|
let cvv = Zeroizing::new(prompt_secret("CVV (blank to skip): ")?);
|
||||||
let cvv = if cvv.is_empty() { None } else { Some(cvv) };
|
let cvv = if cvv.is_empty() { None } else { Some(cvv) };
|
||||||
@@ -209,7 +209,7 @@ fn build_key_item(
|
|||||||
use relicario_core::{Item, ItemCore};
|
use relicario_core::{Item, ItemCore};
|
||||||
use zeroize::Zeroizing;
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
let title = title.map(Ok).unwrap_or_else(|| prompt("Title"))?;
|
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
|
||||||
eprintln!("Paste key material; end with Ctrl-D on a blank line:");
|
eprintln!("Paste key material; end with Ctrl-D on a blank line:");
|
||||||
let mut key_material = String::new();
|
let mut key_material = String::new();
|
||||||
std::io::Read::read_to_string(&mut std::io::stdin(), &mut key_material)?;
|
std::io::Read::read_to_string(&mut std::io::stdin(), &mut key_material)?;
|
||||||
@@ -236,7 +236,7 @@ fn build_document_item(
|
|||||||
use relicario_core::{encrypt_attachment, AttachmentRef, Item, ItemCore};
|
use relicario_core::{encrypt_attachment, AttachmentRef, Item, ItemCore};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
|
|
||||||
let title = title.map(Ok).unwrap_or_else(|| prompt("Title"))?;
|
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
|
||||||
let bytes = fs::read(&file)
|
let bytes = fs::read(&file)
|
||||||
.with_context(|| format!("failed to read {}", file.display()))?;
|
.with_context(|| format!("failed to read {}", file.display()))?;
|
||||||
let caps = vault.load_settings()?.attachment_caps;
|
let caps = vault.load_settings()?.attachment_caps;
|
||||||
@@ -285,7 +285,7 @@ fn build_totp_item(
|
|||||||
use relicario_core::{Item, ItemCore};
|
use relicario_core::{Item, ItemCore};
|
||||||
use zeroize::Zeroizing;
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
let title = title.map(Ok).unwrap_or_else(|| prompt("Title"))?;
|
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
|
||||||
let secret_b32 = match secret {
|
let secret_b32 = match secret {
|
||||||
Some(s) => s,
|
Some(s) => s,
|
||||||
None => prompt_secret("TOTP secret (base32): ")?,
|
None => prompt_secret("TOTP secret (base32): ")?,
|
||||||
|
|||||||
@@ -1,19 +1,47 @@
|
|||||||
//! Thin shims over `relicario-core`'s migrated parsers, kept here so existing
|
//! Small parsers used by the CLI (`MM/YY[YY]`, lenient base32, MIME guess).
|
||||||
//! CLI callsites need no import churn. Plan B Phase 7 moved the bodies into
|
//!
|
||||||
//! `relicario_core::{time::MonthYear::parse, base32::decode_rfc4648_lenient,
|
//! Phase 7 of the CLI restructure migrates these to `relicario-core` and
|
||||||
//! mime::guess_for_extension}`.
|
//! turns this file into a thin re-export shim. They live here for now so
|
||||||
|
//! the Phase 1 relocation stays mechanical.
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::{Context, Result};
|
||||||
use relicario_core::MonthYear;
|
|
||||||
|
|
||||||
pub(crate) fn parse_month_year(s: &str) -> Result<MonthYear> {
|
pub(crate) fn parse_month_year(s: &str) -> Result<relicario_core::MonthYear> {
|
||||||
Ok(MonthYear::parse(s)?)
|
// Accepts MM/YYYY or MM-YYYY or MM/YY.
|
||||||
|
let (m_str, y_str) = s.split_once(['/', '-'])
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("expected MM/YYYY"))?;
|
||||||
|
let month: u8 = m_str.parse().context("invalid month")?;
|
||||||
|
let year: u16 = if y_str.len() == 2 {
|
||||||
|
2000 + y_str.parse::<u16>().context("invalid 2-digit year")?
|
||||||
|
} else {
|
||||||
|
y_str.parse().context("invalid year")?
|
||||||
|
};
|
||||||
|
Ok(relicario_core::MonthYear { month, year })
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn guess_mime(filename: &str) -> String {
|
pub(crate) fn guess_mime(filename: &str) -> String {
|
||||||
relicario_core::mime::guess_for_extension(filename).to_string()
|
let lower = filename.to_ascii_lowercase();
|
||||||
|
match lower.rsplit_once('.').map(|(_, ext)| ext).unwrap_or("") {
|
||||||
|
"pdf" => "application/pdf",
|
||||||
|
"png" => "image/png",
|
||||||
|
"jpg" | "jpeg" => "image/jpeg",
|
||||||
|
"txt" => "text/plain",
|
||||||
|
"json" => "application/json",
|
||||||
|
_ => "application/octet-stream",
|
||||||
|
}.to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn base32_decode_lenient(s: &str) -> Result<Vec<u8>> {
|
pub(crate) fn base32_decode_lenient(s: &str) -> Result<Vec<u8>> {
|
||||||
Ok(relicario_core::base32::decode_rfc4648_lenient(s)?)
|
let cleaned: String = s.chars()
|
||||||
|
.filter(|c| !c.is_whitespace())
|
||||||
|
.collect::<String>()
|
||||||
|
.to_ascii_uppercase()
|
||||||
|
.trim_end_matches('=')
|
||||||
|
.to_string();
|
||||||
|
let padded = {
|
||||||
|
let rem = cleaned.len() % 8;
|
||||||
|
if rem == 0 { cleaned } else { format!("{}{}", cleaned, "=".repeat(8 - rem)) }
|
||||||
|
};
|
||||||
|
data_encoding::BASE32.decode(padded.as_bytes())
|
||||||
|
.map_err(|e| anyhow::anyhow!("invalid base32: {e}"))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,12 @@
|
|||||||
//! used by the edit handlers to keep current values when the user hits enter
|
//! used by the edit handlers to keep current values when the user hits enter
|
||||||
//! at a blank prompt. `prompt_secret` honours `RELICARIO_TEST_ITEM_SECRET`
|
//! at a blank prompt. `prompt_secret` honours `RELICARIO_TEST_ITEM_SECRET`
|
||||||
//! so integration tests (which don't have a TTY) can inject secrets.
|
//! so integration tests (which don't have a TTY) can inject secrets.
|
||||||
|
//! `prompt_or_flag` and `prompt_or_flag_optional` thread a CLI-flag value
|
||||||
|
//! through the same path so command handlers can use one call site whether
|
||||||
|
//! the value came from the command line or from an interactive prompt.
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
use std::io::BufRead;
|
||||||
|
|
||||||
/// `rpassword::prompt_password` wrapper that honours `RELICARIO_TEST_ITEM_SECRET`
|
/// `rpassword::prompt_password` wrapper that honours `RELICARIO_TEST_ITEM_SECRET`
|
||||||
/// for integration-test use (rpassword reads /dev/tty by default, which is
|
/// for integration-test use (rpassword reads /dev/tty by default, which is
|
||||||
@@ -18,25 +22,37 @@ pub(crate) fn prompt_secret(label: &str) -> Result<String> {
|
|||||||
rpassword::prompt_password(label).map_err(Into::into)
|
rpassword::prompt_password(label).map_err(Into::into)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn prompt(label: &str) -> Result<String> {
|
fn read_required_line<R: BufRead>(reader: &mut R, label: &str) -> Result<String> {
|
||||||
eprint!("{label}: ");
|
eprint!("{label}: ");
|
||||||
std::io::Write::flush(&mut std::io::stderr())?;
|
std::io::Write::flush(&mut std::io::stderr())?;
|
||||||
let mut s = String::new();
|
let mut s = String::new();
|
||||||
std::io::stdin().read_line(&mut s)?;
|
reader.read_line(&mut s)?;
|
||||||
let trimmed = s.trim().to_string();
|
let trimmed = s.trim().to_string();
|
||||||
if trimmed.is_empty() { anyhow::bail!("{label} required"); }
|
if trimmed.is_empty() { anyhow::bail!("{label} required"); }
|
||||||
Ok(trimmed)
|
Ok(trimmed)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn prompt_optional(label: &str) -> Result<Option<String>> {
|
fn read_optional_line<R: BufRead>(reader: &mut R, label: &str) -> Result<Option<String>> {
|
||||||
eprint!("{label} (leave blank to skip): ");
|
eprint!("{label} (leave blank to skip): ");
|
||||||
std::io::Write::flush(&mut std::io::stderr())?;
|
std::io::Write::flush(&mut std::io::stderr())?;
|
||||||
let mut s = String::new();
|
let mut s = String::new();
|
||||||
std::io::stdin().read_line(&mut s)?;
|
reader.read_line(&mut s)?;
|
||||||
let trimmed = s.trim().to_string();
|
let trimmed = s.trim().to_string();
|
||||||
Ok(if trimmed.is_empty() { None } else { Some(trimmed) })
|
Ok(if trimmed.is_empty() { None } else { Some(trimmed) })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn prompt(label: &str) -> Result<String> {
|
||||||
|
let stdin = std::io::stdin();
|
||||||
|
let mut reader = std::io::BufReader::new(stdin.lock());
|
||||||
|
read_required_line(&mut reader, label)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn prompt_optional(label: &str) -> Result<Option<String>> {
|
||||||
|
let stdin = std::io::stdin();
|
||||||
|
let mut reader = std::io::BufReader::new(stdin.lock());
|
||||||
|
read_optional_line(&mut reader, label)
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn prompt_keep(label: &str, current: &str) -> Result<Option<String>> {
|
pub(crate) fn prompt_keep(label: &str, current: &str) -> Result<Option<String>> {
|
||||||
eprint!("{label} [{current}]: ");
|
eprint!("{label} [{current}]: ");
|
||||||
std::io::Write::flush(&mut std::io::stderr())?;
|
std::io::Write::flush(&mut std::io::stderr())?;
|
||||||
@@ -63,3 +79,117 @@ pub(crate) fn prompt_yesno(label: &str) -> Result<bool> {
|
|||||||
std::io::stdin().read_line(&mut s)?;
|
std::io::stdin().read_line(&mut s)?;
|
||||||
Ok(matches!(s.trim().to_ascii_lowercase().as_str(), "y" | "yes"))
|
Ok(matches!(s.trim().to_ascii_lowercase().as_str(), "y" | "yes"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn prompt_or_flag<T>(
|
||||||
|
flag: Option<T>,
|
||||||
|
label: &str,
|
||||||
|
parser: impl FnOnce(&str) -> Result<T>,
|
||||||
|
) -> Result<T> {
|
||||||
|
let stdin = std::io::stdin();
|
||||||
|
let mut reader = std::io::BufReader::new(stdin.lock());
|
||||||
|
prompt_or_flag_with_reader(flag, label, parser, &mut reader)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn prompt_or_flag_optional<T>(
|
||||||
|
flag: Option<T>,
|
||||||
|
label: &str,
|
||||||
|
parser: impl FnOnce(&str) -> Result<T>,
|
||||||
|
) -> Result<Option<T>> {
|
||||||
|
let stdin = std::io::stdin();
|
||||||
|
let mut reader = std::io::BufReader::new(stdin.lock());
|
||||||
|
prompt_or_flag_optional_with_reader(flag, label, parser, &mut reader)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn prompt_or_flag_with_reader<T, R: BufRead>(
|
||||||
|
flag: Option<T>,
|
||||||
|
label: &str,
|
||||||
|
parser: impl FnOnce(&str) -> Result<T>,
|
||||||
|
reader: &mut R,
|
||||||
|
) -> Result<T> {
|
||||||
|
if let Some(t) = flag {
|
||||||
|
return Ok(t);
|
||||||
|
}
|
||||||
|
let line = read_required_line(reader, label)?;
|
||||||
|
parser(&line)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn prompt_or_flag_optional_with_reader<T, R: BufRead>(
|
||||||
|
flag: Option<T>,
|
||||||
|
label: &str,
|
||||||
|
parser: impl FnOnce(&str) -> Result<T>,
|
||||||
|
reader: &mut R,
|
||||||
|
) -> Result<Option<T>> {
|
||||||
|
if let Some(t) = flag {
|
||||||
|
return Ok(Some(t));
|
||||||
|
}
|
||||||
|
match read_optional_line(reader, label)? {
|
||||||
|
None => Ok(None),
|
||||||
|
Some(line) => parser(&line).map(Some),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::io::Cursor;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn prompt_or_flag_uses_flag_value_when_some() {
|
||||||
|
let mut reader = Cursor::new(Vec::<u8>::new());
|
||||||
|
let got = prompt_or_flag_with_reader::<String, _>(
|
||||||
|
Some("from-flag".to_string()),
|
||||||
|
"Title",
|
||||||
|
|_| panic!("parser must not run when flag is Some"),
|
||||||
|
&mut reader,
|
||||||
|
).expect("flag value path should succeed");
|
||||||
|
assert_eq!(got, "from-flag");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn prompt_or_flag_prompts_when_none() {
|
||||||
|
let mut reader = Cursor::new(b"prompted\n".to_vec());
|
||||||
|
let got = prompt_or_flag_with_reader::<String, _>(
|
||||||
|
None,
|
||||||
|
"Title",
|
||||||
|
|s| Ok(s.to_string()),
|
||||||
|
&mut reader,
|
||||||
|
).expect("prompt path should succeed");
|
||||||
|
assert_eq!(got, "prompted");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn prompt_or_flag_optional_returns_some_from_flag_without_reading() {
|
||||||
|
let mut reader = Cursor::new(Vec::<u8>::new());
|
||||||
|
let got = prompt_or_flag_optional_with_reader::<String, _>(
|
||||||
|
Some("flag-val".to_string()),
|
||||||
|
"URL",
|
||||||
|
|_| panic!("parser must not run when flag is Some"),
|
||||||
|
&mut reader,
|
||||||
|
).expect("flag value path should succeed");
|
||||||
|
assert_eq!(got, Some("flag-val".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn prompt_or_flag_optional_prompts_and_blank_yields_none() {
|
||||||
|
let mut reader = Cursor::new(b"\n".to_vec());
|
||||||
|
let got = prompt_or_flag_optional_with_reader::<String, _>(
|
||||||
|
None,
|
||||||
|
"URL",
|
||||||
|
|_| panic!("parser must not run on blank input"),
|
||||||
|
&mut reader,
|
||||||
|
).expect("blank prompt should succeed with None");
|
||||||
|
assert_eq!(got, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn prompt_or_flag_optional_prompts_and_value_runs_parser() {
|
||||||
|
let mut reader = Cursor::new(b" 42 \n".to_vec());
|
||||||
|
let got = prompt_or_flag_optional_with_reader::<u32, _>(
|
||||||
|
None,
|
||||||
|
"Number",
|
||||||
|
|s| s.parse::<u32>().map_err(Into::into),
|
||||||
|
&mut reader,
|
||||||
|
).expect("value should parse");
|
||||||
|
assert_eq!(got, Some(42));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,132 +0,0 @@
|
|||||||
//! RFC 4648 base32 codec, no-padding form, lenient on input.
|
|
||||||
//!
|
|
||||||
//! The encoder produces canonical no-padding RFC 4648 output (uppercase ASCII).
|
|
||||||
//! The decoder is lenient: case-insensitive, optional `=` padding, whitespace
|
|
||||||
//! anywhere is stripped before decoding.
|
|
||||||
//!
|
|
||||||
//! Steam Guard's authenticator uses a different (de-ambiguated) alphabet —
|
|
||||||
//! see `crate::item_types::totp::STEAM_ALPHABET`. That codec is intentionally
|
|
||||||
//! NOT routed through this module.
|
|
||||||
|
|
||||||
use crate::error::{RelicarioError, Result};
|
|
||||||
|
|
||||||
const ALPHA: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
|
||||||
|
|
||||||
/// RFC 4648 base32 encoder, no-padding form. Output is uppercase ASCII.
|
|
||||||
pub fn encode_rfc4648(bytes: &[u8]) -> String {
|
|
||||||
let mut out = String::new();
|
|
||||||
let mut buffer: u32 = 0;
|
|
||||||
let mut bits: u32 = 0;
|
|
||||||
for &b in bytes {
|
|
||||||
buffer = (buffer << 8) | (b as u32);
|
|
||||||
bits += 8;
|
|
||||||
while bits >= 5 {
|
|
||||||
let idx = ((buffer >> (bits - 5)) & 0x1f) as usize;
|
|
||||||
out.push(ALPHA[idx] as char);
|
|
||||||
bits -= 5;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if bits > 0 {
|
|
||||||
let idx = ((buffer << (5 - bits)) & 0x1f) as usize;
|
|
||||||
out.push(ALPHA[idx] as char);
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
/// RFC 4648 base32 decoder, lenient on input.
|
|
||||||
///
|
|
||||||
/// Accepts upper- or lower-case letters, optional `=` padding, and whitespace
|
|
||||||
/// anywhere. Trailing bits less than a full byte are silently discarded
|
|
||||||
/// (canonical RFC 4648 decode).
|
|
||||||
pub fn decode_rfc4648_lenient(s: &str) -> Result<Vec<u8>> {
|
|
||||||
let cleaned: String = s
|
|
||||||
.chars()
|
|
||||||
.filter(|c| !c.is_whitespace())
|
|
||||||
.collect::<String>()
|
|
||||||
.to_ascii_uppercase();
|
|
||||||
let trimmed = cleaned.trim_end_matches('=');
|
|
||||||
let mut out: Vec<u8> = Vec::with_capacity(trimmed.len() * 5 / 8);
|
|
||||||
let mut buffer: u32 = 0;
|
|
||||||
let mut bits: u32 = 0;
|
|
||||||
for ch in trimmed.bytes() {
|
|
||||||
let idx = ALPHA.iter().position(|&a| a == ch).ok_or_else(|| {
|
|
||||||
RelicarioError::InvalidBase32(format!("non-alphabet character {:?}", ch as char))
|
|
||||||
})?;
|
|
||||||
buffer = (buffer << 5) | (idx as u32);
|
|
||||||
bits += 5;
|
|
||||||
if bits >= 8 {
|
|
||||||
bits -= 8;
|
|
||||||
out.push(((buffer >> bits) & 0xff) as u8);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(out)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn encode_rfc4648_matches_rfc_test_vectors() {
|
|
||||||
// RFC 4648 §10 test vectors, no-padding form.
|
|
||||||
assert_eq!(encode_rfc4648(b""), "");
|
|
||||||
assert_eq!(encode_rfc4648(b"f"), "MY");
|
|
||||||
assert_eq!(encode_rfc4648(b"fo"), "MZXQ");
|
|
||||||
assert_eq!(encode_rfc4648(b"foo"), "MZXW6");
|
|
||||||
assert_eq!(encode_rfc4648(b"foob"), "MZXW6YQ");
|
|
||||||
assert_eq!(encode_rfc4648(b"fooba"), "MZXW6YTB");
|
|
||||||
assert_eq!(encode_rfc4648(b"foobar"), "MZXW6YTBOI");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn decode_rfc4648_lenient_inverts_encoder_on_known_vectors() {
|
|
||||||
let cases: &[(&str, &[u8])] = &[
|
|
||||||
("", b""),
|
|
||||||
("MY", b"f"),
|
|
||||||
("MZXQ", b"fo"),
|
|
||||||
("MZXW6", b"foo"),
|
|
||||||
("MZXW6YQ", b"foob"),
|
|
||||||
("MZXW6YTB", b"fooba"),
|
|
||||||
("MZXW6YTBOI", b"foobar"),
|
|
||||||
];
|
|
||||||
for (s, want) in cases {
|
|
||||||
assert_eq!(&decode_rfc4648_lenient(s).unwrap()[..], *want);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn decode_rfc4648_lenient_accepts_lowercase_and_mixed_case() {
|
|
||||||
assert_eq!(decode_rfc4648_lenient("mzxw6").unwrap(), b"foo");
|
|
||||||
assert_eq!(decode_rfc4648_lenient("MzXw6yTbOi").unwrap(), b"foobar");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn decode_rfc4648_lenient_strips_optional_padding() {
|
|
||||||
assert_eq!(decode_rfc4648_lenient("MY======").unwrap(), b"f");
|
|
||||||
assert_eq!(decode_rfc4648_lenient("MZXW6===").unwrap(), b"foo");
|
|
||||||
assert_eq!(decode_rfc4648_lenient("MZXW6YTBOI======").unwrap(), b"foobar");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn decode_rfc4648_lenient_strips_whitespace_anywhere() {
|
|
||||||
assert_eq!(decode_rfc4648_lenient(" MZXW 6YTB OI ").unwrap(), b"foobar");
|
|
||||||
assert_eq!(decode_rfc4648_lenient("MZXW\n6YTB\tOI").unwrap(), b"foobar");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn decode_rfc4648_lenient_rejects_non_alphabet_chars() {
|
|
||||||
assert!(matches!(
|
|
||||||
decode_rfc4648_lenient("MY1"),
|
|
||||||
Err(RelicarioError::InvalidBase32(_))
|
|
||||||
));
|
|
||||||
assert!(decode_rfc4648_lenient("???").is_err());
|
|
||||||
assert!(decode_rfc4648_lenient("MZ!XW").is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn encode_decode_round_trips_arbitrary_bytes() {
|
|
||||||
let bytes: Vec<u8> = (0u8..=255).collect();
|
|
||||||
let encoded = encode_rfc4648(&bytes);
|
|
||||||
assert_eq!(decode_rfc4648_lenient(&encoded).unwrap(), bytes);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -123,17 +123,6 @@ pub enum RelicarioError {
|
|||||||
/// Recovery QR generation or parsing failed.
|
/// Recovery QR generation or parsing failed.
|
||||||
#[error("recovery QR: {0}")]
|
#[error("recovery QR: {0}")]
|
||||||
RecoveryQr(String),
|
RecoveryQr(String),
|
||||||
|
|
||||||
/// Base32 decoding failed (non-alphabet character or other malformed
|
|
||||||
/// input). Emitted by [`crate::base32::decode_rfc4648_lenient`] and any
|
|
||||||
/// typed wrappers that delegate to it.
|
|
||||||
#[error("invalid base32: {0}")]
|
|
||||||
InvalidBase32(String),
|
|
||||||
|
|
||||||
/// Card-expiry month/year string failed to parse. Emitted by
|
|
||||||
/// [`crate::time::MonthYear::parse`].
|
|
||||||
#[error("invalid month/year: {0}")]
|
|
||||||
InvalidMonthYear(String),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Crate-wide result alias, reducing boilerplate in function signatures.
|
/// Crate-wide result alias, reducing boilerplate in function signatures.
|
||||||
|
|||||||
@@ -158,8 +158,8 @@ fn map_row(
|
|||||||
let totp = if totp_raw.is_empty() {
|
let totp = if totp_raw.is_empty() {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
match crate::base32::decode_rfc4648_lenient(totp_raw) {
|
match decode_base32_totp(totp_raw) {
|
||||||
Ok(bytes) if !bytes.is_empty() => Some(crate::item_types::TotpConfig {
|
Some(bytes) if !bytes.is_empty() => Some(crate::item_types::TotpConfig {
|
||||||
secret: Zeroizing::new(bytes),
|
secret: Zeroizing::new(bytes),
|
||||||
algorithm: crate::item_types::TotpAlgorithm::Sha1,
|
algorithm: crate::item_types::TotpAlgorithm::Sha1,
|
||||||
digits: 6,
|
digits: 6,
|
||||||
@@ -196,3 +196,25 @@ fn map_row(
|
|||||||
(Some(item), warning)
|
(Some(item), warning)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Decode a base32-encoded TOTP secret per RFC 4648, case-insensitive,
|
||||||
|
/// padding optional. Returns None if the input contains any non-alphabet
|
||||||
|
/// character (after upper-casing). Used by the LastPass importer.
|
||||||
|
fn decode_base32_totp(secret: &str) -> Option<Vec<u8>> {
|
||||||
|
const ALPHA: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
||||||
|
let upper = secret.trim().trim_end_matches('=').to_ascii_uppercase();
|
||||||
|
if upper.is_empty() { return None; }
|
||||||
|
|
||||||
|
let mut out = Vec::with_capacity(upper.len() * 5 / 8);
|
||||||
|
let mut buffer: u32 = 0;
|
||||||
|
let mut bits: u32 = 0;
|
||||||
|
for ch in upper.bytes() {
|
||||||
|
let idx = ALPHA.iter().position(|&a| a == ch)?;
|
||||||
|
buffer = (buffer << 5) | (idx as u32);
|
||||||
|
bits += 5;
|
||||||
|
if bits >= 8 {
|
||||||
|
bits -= 8;
|
||||||
|
out.push(((buffer >> bits) & 0xFF) as u8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(out)
|
||||||
|
}
|
||||||
|
|||||||
@@ -244,7 +244,7 @@ fn serialize_history_value(value: &FieldValue) -> Result<Zeroizing<String>> {
|
|||||||
FieldValue::Concealed(c) => Zeroizing::new(c.as_str().to_owned()),
|
FieldValue::Concealed(c) => Zeroizing::new(c.as_str().to_owned()),
|
||||||
FieldValue::Totp(cfg) => {
|
FieldValue::Totp(cfg) => {
|
||||||
// Store the base32-encoded secret string for human-recognizability.
|
// Store the base32-encoded secret string for human-recognizability.
|
||||||
let s = crate::base32::encode_rfc4648(&cfg.secret);
|
let s = base32_encode(&cfg.secret);
|
||||||
Zeroizing::new(s)
|
Zeroizing::new(s)
|
||||||
}
|
}
|
||||||
_ => return Err(RelicarioError::Format("not a history-tracked kind".into())),
|
_ => return Err(RelicarioError::Format("not a history-tracked kind".into())),
|
||||||
@@ -252,6 +252,28 @@ fn serialize_history_value(value: &FieldValue) -> Result<Zeroizing<String>> {
|
|||||||
Ok(s)
|
Ok(s)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Minimal RFC 4648 base32 (no padding) for TOTP secret history serialization.
|
||||||
|
fn base32_encode(bytes: &[u8]) -> String {
|
||||||
|
const ALPHA: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
||||||
|
let mut out = String::new();
|
||||||
|
let mut buffer: u32 = 0;
|
||||||
|
let mut bits: u32 = 0;
|
||||||
|
for &b in bytes {
|
||||||
|
buffer = (buffer << 8) | (b as u32);
|
||||||
|
bits += 8;
|
||||||
|
while bits >= 5 {
|
||||||
|
let idx = ((buffer >> (bits - 5)) & 0x1f) as usize;
|
||||||
|
out.push(ALPHA[idx] as char);
|
||||||
|
bits -= 5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if bits > 0 {
|
||||||
|
let idx = ((buffer << (5 - bits)) & 0x1f) as usize;
|
||||||
|
out.push(ALPHA[idx] as char);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -10,9 +10,6 @@ use crate::error::{RelicarioError, Result};
|
|||||||
|
|
||||||
/// Steam Mobile Authenticator's 5-character output alphabet.
|
/// Steam Mobile Authenticator's 5-character output alphabet.
|
||||||
/// Deliberately excludes ambiguous glyphs (0/O, 1/I/L, S/5, A/Z).
|
/// Deliberately excludes ambiguous glyphs (0/O, 1/I/L, S/5, A/Z).
|
||||||
///
|
|
||||||
/// Not RFC 4648 — Steam Guard's de-ambiguated alphabet; see [`crate::base32`]
|
|
||||||
/// for the standard implementation.
|
|
||||||
const STEAM_ALPHABET: &[u8] = b"23456789BCDFGHJKMNPQRTVWXY";
|
const STEAM_ALPHABET: &[u8] = b"23456789BCDFGHJKMNPQRTVWXY";
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
@@ -24,14 +21,6 @@ pub struct TotpCore {
|
|||||||
pub label: Option<String>,
|
pub label: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TotpConfig {
|
|
||||||
/// Decode a base32-encoded TOTP secret (RFC 4648, lenient input) into the
|
|
||||||
/// canonical `Zeroizing<Vec<u8>>` form used in [`Self::secret`].
|
|
||||||
pub fn parse_secret(s: &str) -> Result<Zeroizing<Vec<u8>>> {
|
|
||||||
Ok(Zeroizing::new(crate::base32::decode_rfc4648_lenient(s)?))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct TotpConfig {
|
pub struct TotpConfig {
|
||||||
/// Raw bytes of the TOTP secret (decoded from base32 when imported).
|
/// Raw bytes of the TOTP secret (decoded from base32 when imported).
|
||||||
|
|||||||
@@ -14,8 +14,6 @@
|
|||||||
//! - [`crypto`] — Argon2id KDF (length-prefixed inputs, Zeroizing output) and
|
//! - [`crypto`] — Argon2id KDF (length-prefixed inputs, Zeroizing output) and
|
||||||
//! XChaCha20-Poly1305 AEAD with VERSION_BYTE 0x02.
|
//! XChaCha20-Poly1305 AEAD with VERSION_BYTE 0x02.
|
||||||
//! - [`ids`] — `ItemId`, `FieldId`, and content-addressed `AttachmentId`.
|
//! - [`ids`] — `ItemId`, `FieldId`, and content-addressed `AttachmentId`.
|
||||||
//! - [`base32`] — RFC 4648 base32 codec used for TOTP secret encode/decode.
|
|
||||||
//! - [`mime`] — Filename-extension → MIME-type guess for attachment storage.
|
|
||||||
//! - [`time`] — unix-seconds + `MonthYear` for card expiries.
|
//! - [`time`] — unix-seconds + `MonthYear` for card expiries.
|
||||||
//! - [`item_types`] — Per-type cores (`LoginCore`, `SecureNoteCore`, etc.) and the
|
//! - [`item_types`] — Per-type cores (`LoginCore`, `SecureNoteCore`, etc.) and the
|
||||||
//! `ItemCore`/`ItemType` enums.
|
//! `ItemCore`/`ItemType` enums.
|
||||||
@@ -48,10 +46,6 @@ pub use crypto::{decrypt, derive_master_key, encrypt, KdfParams, VERSION_BYTE};
|
|||||||
pub mod ids;
|
pub mod ids;
|
||||||
pub use ids::{AttachmentId, FieldId, ItemId};
|
pub use ids::{AttachmentId, FieldId, ItemId};
|
||||||
|
|
||||||
pub mod base32;
|
|
||||||
|
|
||||||
pub mod mime;
|
|
||||||
|
|
||||||
pub mod time;
|
pub mod time;
|
||||||
pub use time::{now_unix, MonthYear};
|
pub use time::{now_unix, MonthYear};
|
||||||
|
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
//! Tiny extension → MIME map for the small set of file types Relicario
|
|
||||||
//! attaches today. Unknown extensions fall back to `application/octet-stream`.
|
|
||||||
|
|
||||||
/// Guess a MIME type from a filename's extension. Case-insensitive.
|
|
||||||
pub fn guess_for_extension(filename: &str) -> &'static str {
|
|
||||||
let lower = filename.to_ascii_lowercase();
|
|
||||||
match lower.rsplit_once('.').map(|(_, ext)| ext).unwrap_or("") {
|
|
||||||
"pdf" => "application/pdf",
|
|
||||||
"png" => "image/png",
|
|
||||||
"jpg" | "jpeg" => "image/jpeg",
|
|
||||||
"txt" => "text/plain",
|
|
||||||
"json" => "application/json",
|
|
||||||
_ => "application/octet-stream",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn known_extensions_match() {
|
|
||||||
assert_eq!(guess_for_extension("doc.pdf"), "application/pdf");
|
|
||||||
assert_eq!(guess_for_extension("photo.png"), "image/png");
|
|
||||||
assert_eq!(guess_for_extension("photo.jpg"), "image/jpeg");
|
|
||||||
assert_eq!(guess_for_extension("photo.jpeg"), "image/jpeg");
|
|
||||||
assert_eq!(guess_for_extension("notes.txt"), "text/plain");
|
|
||||||
assert_eq!(guess_for_extension("data.json"), "application/json");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn extension_match_is_case_insensitive() {
|
|
||||||
assert_eq!(guess_for_extension("doc.PDF"), "application/pdf");
|
|
||||||
assert_eq!(guess_for_extension("photo.JPEG"), "image/jpeg");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn unknown_or_missing_extension_falls_back() {
|
|
||||||
assert_eq!(guess_for_extension("unknown.xyz"), "application/octet-stream");
|
|
||||||
assert_eq!(guess_for_extension("noextension"), "application/octet-stream");
|
|
||||||
assert_eq!(guess_for_extension(""), "application/octet-stream");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn uses_extension_after_last_dot() {
|
|
||||||
assert_eq!(guess_for_extension("path/to/file.pdf"), "application/pdf");
|
|
||||||
assert_eq!(guess_for_extension("archive.tar.gz"), "application/octet-stream");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,8 +2,6 @@
|
|||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::error::{RelicarioError, Result};
|
|
||||||
|
|
||||||
/// Current Unix timestamp in seconds.
|
/// Current Unix timestamp in seconds.
|
||||||
pub fn now_unix() -> i64 {
|
pub fn now_unix() -> i64 {
|
||||||
chrono::Utc::now().timestamp()
|
chrono::Utc::now().timestamp()
|
||||||
@@ -17,7 +15,7 @@ pub struct MonthYear {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl MonthYear {
|
impl MonthYear {
|
||||||
pub fn new(month: u8, year: u16) -> std::result::Result<Self, &'static str> {
|
pub fn new(month: u8, year: u16) -> Result<Self, &'static str> {
|
||||||
if !(1..=12).contains(&month) {
|
if !(1..=12).contains(&month) {
|
||||||
return Err("month must be 1..=12");
|
return Err("month must be 1..=12");
|
||||||
}
|
}
|
||||||
@@ -26,28 +24,6 @@ impl MonthYear {
|
|||||||
}
|
}
|
||||||
Ok(Self { month, year })
|
Ok(Self { month, year })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse a card-expiry string. Accepts `MM/YYYY`, `MM-YYYY`, and `MM/YY`
|
|
||||||
/// (two-digit year is taken as 20YY).
|
|
||||||
pub fn parse(s: &str) -> Result<Self> {
|
|
||||||
let invalid = |detail: String| RelicarioError::InvalidMonthYear(detail);
|
|
||||||
let (m_str, y_str) = s
|
|
||||||
.split_once(['/', '-'])
|
|
||||||
.ok_or_else(|| invalid(format!("expected MM/YYYY, got {s:?}")))?;
|
|
||||||
let month: u8 = m_str
|
|
||||||
.parse()
|
|
||||||
.map_err(|_| invalid(format!("bad month {m_str:?}")))?;
|
|
||||||
let year: u16 = if y_str.len() == 2 {
|
|
||||||
2000 + y_str
|
|
||||||
.parse::<u16>()
|
|
||||||
.map_err(|_| invalid(format!("bad 2-digit year {y_str:?}")))?
|
|
||||||
} else {
|
|
||||||
y_str
|
|
||||||
.parse()
|
|
||||||
.map_err(|_| invalid(format!("bad year {y_str:?}")))?
|
|
||||||
};
|
|
||||||
Self::new(month, year).map_err(|e| invalid(e.into()))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -84,30 +60,4 @@ mod tests {
|
|||||||
let parsed: MonthYear = serde_json::from_str(&json).unwrap();
|
let parsed: MonthYear = serde_json::from_str(&json).unwrap();
|
||||||
assert_eq!(parsed, my);
|
assert_eq!(parsed, my);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_accepts_mm_slash_yyyy_and_mm_dash_yyyy() {
|
|
||||||
assert_eq!(MonthYear::parse("01/2026").unwrap(), MonthYear::new(1, 2026).unwrap());
|
|
||||||
assert_eq!(MonthYear::parse("12/2099").unwrap(), MonthYear::new(12, 2099).unwrap());
|
|
||||||
assert_eq!(MonthYear::parse("07-2030").unwrap(), MonthYear::new(7, 2030).unwrap());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_accepts_mm_slash_yy() {
|
|
||||||
assert_eq!(MonthYear::parse("01/26").unwrap(), MonthYear::new(1, 2026).unwrap());
|
|
||||||
assert_eq!(MonthYear::parse("12/99").unwrap(), MonthYear::new(12, 2099).unwrap());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn parse_rejects_malformed() {
|
|
||||||
assert!(matches!(
|
|
||||||
MonthYear::parse("garbage"),
|
|
||||||
Err(RelicarioError::InvalidMonthYear(_))
|
|
||||||
));
|
|
||||||
assert!(MonthYear::parse("13/2026").is_err()); // bad month
|
|
||||||
assert!(MonthYear::parse("01/1999").is_err()); // pre-2000
|
|
||||||
assert!(MonthYear::parse("01/2100").is_err()); // post-2099
|
|
||||||
assert!(MonthYear::parse("/2026").is_err()); // empty month
|
|
||||||
assert!(MonthYear::parse("01/").is_err()); // empty year
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -330,32 +330,6 @@ pub fn embed_image_secret(carrier: &[u8], secret: &[u8]) -> Result<Vec<u8>, JsEr
|
|||||||
imgsecret::embed(carrier, s).map_err(|e| JsError::new(&e.to_string()))
|
imgsecret::embed(carrier, s).map_err(|e| JsError::new(&e.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Pure parsers (no session needed) ────────────────────────────────────────
|
|
||||||
|
|
||||||
use relicario_core::{base32 as core_base32, mime as core_mime, MonthYear};
|
|
||||||
|
|
||||||
/// Parse a card-expiry string (`MM/YYYY` / `MM-YYYY` / `MM/YY`).
|
|
||||||
/// Returns a plain `{ month, year }` object on success.
|
|
||||||
#[wasm_bindgen]
|
|
||||||
pub fn parse_month_year(s: &str) -> Result<JsValue, JsError> {
|
|
||||||
let my = MonthYear::parse(s).map_err(|e| JsError::new(&e.to_string()))?;
|
|
||||||
js_value_for(&my)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Decode an RFC 4648 base32 string (case-insensitive, optional padding,
|
|
||||||
/// whitespace-stripped). Returned as `Uint8Array` on the JS side.
|
|
||||||
#[wasm_bindgen]
|
|
||||||
pub fn base32_decode_lenient(s: &str) -> Result<Vec<u8>, JsError> {
|
|
||||||
core_base32::decode_rfc4648_lenient(s).map_err(|e| JsError::new(&e.to_string()))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Guess a MIME type from a filename's extension. Returns
|
|
||||||
/// `application/octet-stream` for unknown or missing extensions.
|
|
||||||
#[wasm_bindgen]
|
|
||||||
pub fn guess_mime(filename: &str) -> String {
|
|
||||||
core_mime::guess_for_extension(filename).to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
use relicario_core::item_types::{TotpConfig, compute_totp_code};
|
use relicario_core::item_types::{TotpConfig, compute_totp_code};
|
||||||
|
|
||||||
#[wasm_bindgen]
|
#[wasm_bindgen]
|
||||||
@@ -650,24 +624,4 @@ mod session_tests {
|
|||||||
// Should fail with a header validation error.
|
// Should fail with a header validation error.
|
||||||
assert!(err.is_err());
|
assert!(err.is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn base32_decode_lenient_round_trips_known_vector() {
|
|
||||||
let bytes = super::base32_decode_lenient("MZXW6YTBOI").unwrap();
|
|
||||||
assert_eq!(bytes, b"foobar");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn guess_mime_known_and_unknown_extensions() {
|
|
||||||
assert_eq!(super::guess_mime("doc.pdf"), "application/pdf");
|
|
||||||
assert_eq!(super::guess_mime("photo.JPEG"), "image/jpeg");
|
|
||||||
assert_eq!(super::guess_mime("file.xyz"), "application/octet-stream");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Error paths and JsValue serialization can't be exercised natively —
|
|
||||||
// JsError::new and serde_wasm_bindgen::Serializer call wasm-bindgen
|
|
||||||
// imports that panic off-wasm (same constraint as
|
|
||||||
// `parse_lastpass_csv_json_propagates_header_errors` above). Those
|
|
||||||
// paths are covered in core: `time::tests::parse_rejects_malformed`
|
|
||||||
// and `base32::tests::decode_rfc4648_lenient_rejects_non_alphabet_chars`.
|
|
||||||
}
|
}
|
||||||
|
|||||||
5
extension/src/wasm.d.ts
vendored
5
extension/src/wasm.d.ts
vendored
@@ -59,11 +59,6 @@ declare module 'relicario-wasm' {
|
|||||||
export function extract_image_secret(image_bytes: Uint8Array): Uint8Array;
|
export function extract_image_secret(image_bytes: Uint8Array): Uint8Array;
|
||||||
export function embed_image_secret(carrier: Uint8Array, secret: Uint8Array): Uint8Array;
|
export function embed_image_secret(carrier: Uint8Array, secret: Uint8Array): Uint8Array;
|
||||||
|
|
||||||
// Pure parsers (no session needed)
|
|
||||||
export function parse_month_year(s: string): { month: number; year: number };
|
|
||||||
export function base32_decode_lenient(s: string): Uint8Array;
|
|
||||||
export function guess_mime(filename: string): string;
|
|
||||||
|
|
||||||
export function totp_compute(config_json: string, now_unix_seconds: bigint): TotpCode;
|
export function totp_compute(config_json: string, now_unix_seconds: bigint): TotpCode;
|
||||||
|
|
||||||
export function register_device(name: string): {
|
export function register_device(name: string): {
|
||||||
|
|||||||
Reference in New Issue
Block a user