3 Commits

Author SHA1 Message Date
adlee-was-taken
fc9264e9ae feat(wasm): add parse_month_year, base32_decode_lenient, guess_mime exports
Plan B Phase 8 — three #[wasm_bindgen] exports for the parsers migrated
in Phase 7, mirrored in extension/src/wasm.d.ts under "Pure parsers
(no session needed)". snake_case JS naming consistent with every
existing export; SessionHandle not required.

- parse_month_year(s) → { month, year } via js_value_for
- base32_decode_lenient(s) → Uint8Array
- guess_mime(filename) → string

Tests in session_tests mod cover the OK paths; error-path / JsValue
serialization can't be tested natively (JsError construction panics
off-wasm) and is covered in core (time::tests + base32::tests).

Plan C will wire SW message handlers consuming these exports in a
future round; this commit delivers only the seam.

Includes simplify-feedback fixes:
- relicario-core lib.rs module-list mentions base32 and mime
- item_types/totp.rs neighbour comment unified to ///-style block

cargo test --workspace: green
cargo clippy --workspace: silent
cargo build -p relicario-wasm --target wasm32-unknown-unknown: clean
cd extension && npm run test: 17 pre-existing failures only (baseline)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 11:33:40 -04:00
adlee-was-taken
03f2a1b58e refactor(core,cli): migrate CLI parsers to relicario-core, parse.rs becomes shim
Plan B Phase 7 sub-step 2 — moves the bodies of parse_month_year,
base32_decode_lenient, guess_mime from crates/relicario-cli/src/parse.rs
to relicario-core. The CLI's parse.rs becomes a 19-line shim re-exporting
the new core API.

New core surface:
- time::MonthYear::parse (Result<_, RelicarioError>)
- mime::guess_for_extension (new mime module)
- item_types::TotpConfig::parse_secret — Zeroizing<Vec<u8>> wrapper
  over base32::decode_rfc4648_lenient

base32 module promoted from pub(crate) to pub so non-core consumers
(CLI shim, future Phase 8 WASM exports) can reach it. New
RelicarioError::InvalidMonthYear(String) for the parse error path
(mirrors sub-step 1's InvalidBase32). MonthYear::new keeps its
&'static str error type — bringing it to RelicarioError is DEV-A's P3.

CLI callsites unchanged (commands/{add,edit,attach}.rs); RelicarioError
auto-converts to anyhow::Error at ? boundaries.

cargo test --workspace: green (core 143, +7 from new tests)
cargo clippy --workspace: silent

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 11:12:05 -04:00
adlee-was-taken
e5d63ab196 refactor(core): extract base32 module, dedupe two RFC 4648 impls
New crates/relicario-core/src/base32.rs hosts encode_rfc4648 +
decode_rfc4648_lenient (case-insensitive, optional padding, whitespace
stripped). Folds inline base32_encode (item.rs:255-275) and
decode_base32_totp (import_lastpass.rs:202-220) into the shared module;
both call sites updated.

- New RelicarioError::InvalidBase32(String) variant for the decoder
  error path
- Module is pub(crate); public API surface unchanged
- Steam alphabet (item_types/totp.rs:13) intentionally separate with
  neighbour comment pointing at crate::base32

Plan B Phase 7 sub-step 1 (DEV-A P2 base32 dedup half).
docs/superpowers/specs/2026-05-04-cli-restructure-design.md.

cargo test --workspace: green
cargo clippy --workspace: silent

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 10:46:17 -04:00
11 changed files with 324 additions and 86 deletions

View File

@@ -1,47 +1,19 @@
//! Small parsers used by the CLI (`MM/YY[YY]`, lenient base32, MIME guess). //! Thin shims over `relicario-core`'s migrated parsers, kept here so existing
//! //! CLI callsites need no import churn. Plan B Phase 7 moved the bodies into
//! Phase 7 of the CLI restructure migrates these to `relicario-core` and //! `relicario_core::{time::MonthYear::parse, base32::decode_rfc4648_lenient,
//! turns this file into a thin re-export shim. They live here for now so //! mime::guess_for_extension}`.
//! the Phase 1 relocation stays mechanical.
use anyhow::{Context, Result}; use anyhow::Result;
use relicario_core::MonthYear;
pub(crate) fn parse_month_year(s: &str) -> Result<relicario_core::MonthYear> { pub(crate) fn parse_month_year(s: &str) -> Result<MonthYear> {
// Accepts MM/YYYY or MM-YYYY or MM/YY. Ok(MonthYear::parse(s)?)
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 {
let lower = filename.to_ascii_lowercase(); relicario_core::mime::guess_for_extension(filename).to_string()
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>> {
let cleaned: String = s.chars() Ok(relicario_core::base32::decode_rfc4648_lenient(s)?)
.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}"))
} }

View File

@@ -0,0 +1,132 @@
//! 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);
}
}

View File

@@ -123,6 +123,17 @@ 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.

View File

@@ -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 decode_base32_totp(totp_raw) { match crate::base32::decode_rfc4648_lenient(totp_raw) {
Some(bytes) if !bytes.is_empty() => Some(crate::item_types::TotpConfig { Ok(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,25 +196,3 @@ 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)
}

View File

@@ -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 = base32_encode(&cfg.secret); let s = crate::base32::encode_rfc4648(&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,28 +252,6 @@ 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::*;

View File

@@ -10,6 +10,9 @@ 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)]
@@ -21,6 +24,14 @@ 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).

View File

@@ -14,6 +14,8 @@
//! - [`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.
@@ -46,6 +48,10 @@ 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};

View File

@@ -0,0 +1,49 @@
//! 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");
}
}

View File

@@ -2,6 +2,8 @@
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()
@@ -15,7 +17,7 @@ pub struct MonthYear {
} }
impl MonthYear { impl MonthYear {
pub fn new(month: u8, year: u16) -> Result<Self, &'static str> { pub fn new(month: u8, year: u16) -> std::result::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");
} }
@@ -24,6 +26,28 @@ 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)]
@@ -60,4 +84,30 @@ 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
}
} }

View File

@@ -330,6 +330,32 @@ 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]
@@ -624,4 +650,24 @@ 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`.
} }

View File

@@ -59,6 +59,11 @@ 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): {