From e5d63ab196786b07f6f5c1ddac94c33dbf631d95 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sat, 9 May 2026 10:44:16 -0400 Subject: [PATCH 1/3] 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 --- crates/relicario-core/src/base32.rs | 132 +++++++++++++++++++ crates/relicario-core/src/error.rs | 6 + crates/relicario-core/src/import_lastpass.rs | 26 +--- crates/relicario-core/src/item.rs | 24 +--- crates/relicario-core/src/item_types/totp.rs | 3 + crates/relicario-core/src/lib.rs | 2 + 6 files changed, 146 insertions(+), 47 deletions(-) create mode 100644 crates/relicario-core/src/base32.rs diff --git a/crates/relicario-core/src/base32.rs b/crates/relicario-core/src/base32.rs new file mode 100644 index 0000000..a7057b3 --- /dev/null +++ b/crates/relicario-core/src/base32.rs @@ -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> { + let cleaned: String = s + .chars() + .filter(|c| !c.is_whitespace()) + .collect::() + .to_ascii_uppercase(); + let trimmed = cleaned.trim_end_matches('='); + let mut out: Vec = 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 = (0u8..=255).collect(); + let encoded = encode_rfc4648(&bytes); + assert_eq!(decode_rfc4648_lenient(&encoded).unwrap(), bytes); + } +} diff --git a/crates/relicario-core/src/error.rs b/crates/relicario-core/src/error.rs index 076d1f3..059c443 100644 --- a/crates/relicario-core/src/error.rs +++ b/crates/relicario-core/src/error.rs @@ -123,6 +123,12 @@ pub enum RelicarioError { /// Recovery QR generation or parsing failed. #[error("recovery QR: {0}")] 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), } /// Crate-wide result alias, reducing boilerplate in function signatures. diff --git a/crates/relicario-core/src/import_lastpass.rs b/crates/relicario-core/src/import_lastpass.rs index 23f2be4..6ad69eb 100644 --- a/crates/relicario-core/src/import_lastpass.rs +++ b/crates/relicario-core/src/import_lastpass.rs @@ -158,8 +158,8 @@ fn map_row( let totp = if totp_raw.is_empty() { None } else { - match decode_base32_totp(totp_raw) { - Some(bytes) if !bytes.is_empty() => Some(crate::item_types::TotpConfig { + match crate::base32::decode_rfc4648_lenient(totp_raw) { + Ok(bytes) if !bytes.is_empty() => Some(crate::item_types::TotpConfig { secret: Zeroizing::new(bytes), algorithm: crate::item_types::TotpAlgorithm::Sha1, digits: 6, @@ -196,25 +196,3 @@ fn map_row( (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> { - 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) -} diff --git a/crates/relicario-core/src/item.rs b/crates/relicario-core/src/item.rs index fa65613..ca14c84 100644 --- a/crates/relicario-core/src/item.rs +++ b/crates/relicario-core/src/item.rs @@ -244,7 +244,7 @@ fn serialize_history_value(value: &FieldValue) -> Result> { FieldValue::Concealed(c) => Zeroizing::new(c.as_str().to_owned()), FieldValue::Totp(cfg) => { // 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) } _ => return Err(RelicarioError::Format("not a history-tracked kind".into())), @@ -252,28 +252,6 @@ fn serialize_history_value(value: &FieldValue) -> Result> { 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)] mod tests { use super::*; diff --git a/crates/relicario-core/src/item_types/totp.rs b/crates/relicario-core/src/item_types/totp.rs index 83c1052..0c16e32 100644 --- a/crates/relicario-core/src/item_types/totp.rs +++ b/crates/relicario-core/src/item_types/totp.rs @@ -10,6 +10,9 @@ use crate::error::{RelicarioError, Result}; /// Steam Mobile Authenticator's 5-character output alphabet. /// 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"; #[derive(Debug, Clone, Serialize, Deserialize, Default)] diff --git a/crates/relicario-core/src/lib.rs b/crates/relicario-core/src/lib.rs index 0d397c6..c4b3030 100644 --- a/crates/relicario-core/src/lib.rs +++ b/crates/relicario-core/src/lib.rs @@ -46,6 +46,8 @@ pub use crypto::{decrypt, derive_master_key, encrypt, KdfParams, VERSION_BYTE}; pub mod ids; pub use ids::{AttachmentId, FieldId, ItemId}; +pub(crate) mod base32; + pub mod time; pub use time::{now_unix, MonthYear}; From 03f2a1b58efc136e5758a5c2620fd3207d52966a Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sat, 9 May 2026 11:12:05 -0400 Subject: [PATCH 2/3] refactor(core,cli): migrate CLI parsers to relicario-core, parse.rs becomes shim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> 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 --- crates/relicario-cli/src/parse.rs | 48 ++++-------------- crates/relicario-core/src/error.rs | 5 ++ crates/relicario-core/src/item_types/totp.rs | 8 +++ crates/relicario-core/src/lib.rs | 4 +- crates/relicario-core/src/mime.rs | 49 ++++++++++++++++++ crates/relicario-core/src/time.rs | 52 +++++++++++++++++++- 6 files changed, 126 insertions(+), 40 deletions(-) create mode 100644 crates/relicario-core/src/mime.rs diff --git a/crates/relicario-cli/src/parse.rs b/crates/relicario-cli/src/parse.rs index cbf8084..06993d8 100644 --- a/crates/relicario-cli/src/parse.rs +++ b/crates/relicario-cli/src/parse.rs @@ -1,47 +1,19 @@ -//! Small parsers used by the CLI (`MM/YY[YY]`, lenient base32, MIME guess). -//! -//! Phase 7 of the CLI restructure migrates these to `relicario-core` and -//! turns this file into a thin re-export shim. They live here for now so -//! the Phase 1 relocation stays mechanical. +//! 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 +//! `relicario_core::{time::MonthYear::parse, base32::decode_rfc4648_lenient, +//! mime::guess_for_extension}`. -use anyhow::{Context, Result}; +use anyhow::Result; +use relicario_core::MonthYear; -pub(crate) fn parse_month_year(s: &str) -> Result { - // 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::().context("invalid 2-digit year")? - } else { - y_str.parse().context("invalid year")? - }; - Ok(relicario_core::MonthYear { month, year }) +pub(crate) fn parse_month_year(s: &str) -> Result { + Ok(MonthYear::parse(s)?) } pub(crate) fn guess_mime(filename: &str) -> 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() + relicario_core::mime::guess_for_extension(filename).to_string() } pub(crate) fn base32_decode_lenient(s: &str) -> Result> { - let cleaned: String = s.chars() - .filter(|c| !c.is_whitespace()) - .collect::() - .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}")) + Ok(relicario_core::base32::decode_rfc4648_lenient(s)?) } diff --git a/crates/relicario-core/src/error.rs b/crates/relicario-core/src/error.rs index 059c443..8839eaf 100644 --- a/crates/relicario-core/src/error.rs +++ b/crates/relicario-core/src/error.rs @@ -129,6 +129,11 @@ pub enum RelicarioError { /// 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. diff --git a/crates/relicario-core/src/item_types/totp.rs b/crates/relicario-core/src/item_types/totp.rs index 0c16e32..00e7b80 100644 --- a/crates/relicario-core/src/item_types/totp.rs +++ b/crates/relicario-core/src/item_types/totp.rs @@ -24,6 +24,14 @@ pub struct TotpCore { pub label: Option, } +impl TotpConfig { + /// Decode a base32-encoded TOTP secret (RFC 4648, lenient input) into the + /// canonical `Zeroizing>` form used in [`Self::secret`]. + pub fn parse_secret(s: &str) -> Result>> { + Ok(Zeroizing::new(crate::base32::decode_rfc4648_lenient(s)?)) + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TotpConfig { /// Raw bytes of the TOTP secret (decoded from base32 when imported). diff --git a/crates/relicario-core/src/lib.rs b/crates/relicario-core/src/lib.rs index c4b3030..dbdc33a 100644 --- a/crates/relicario-core/src/lib.rs +++ b/crates/relicario-core/src/lib.rs @@ -46,7 +46,9 @@ pub use crypto::{decrypt, derive_master_key, encrypt, KdfParams, VERSION_BYTE}; pub mod ids; pub use ids::{AttachmentId, FieldId, ItemId}; -pub(crate) mod base32; +pub mod base32; + +pub mod mime; pub mod time; pub use time::{now_unix, MonthYear}; diff --git a/crates/relicario-core/src/mime.rs b/crates/relicario-core/src/mime.rs new file mode 100644 index 0000000..16f006e --- /dev/null +++ b/crates/relicario-core/src/mime.rs @@ -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"); + } +} diff --git a/crates/relicario-core/src/time.rs b/crates/relicario-core/src/time.rs index 7ca263f..28cc1bf 100644 --- a/crates/relicario-core/src/time.rs +++ b/crates/relicario-core/src/time.rs @@ -2,6 +2,8 @@ use serde::{Deserialize, Serialize}; +use crate::error::{RelicarioError, Result}; + /// Current Unix timestamp in seconds. pub fn now_unix() -> i64 { chrono::Utc::now().timestamp() @@ -15,7 +17,7 @@ pub struct MonthYear { } impl MonthYear { - pub fn new(month: u8, year: u16) -> Result { + pub fn new(month: u8, year: u16) -> std::result::Result { if !(1..=12).contains(&month) { return Err("month must be 1..=12"); } @@ -24,6 +26,28 @@ impl MonthYear { } 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 { + 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::() + .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)] @@ -60,4 +84,30 @@ mod tests { let parsed: MonthYear = serde_json::from_str(&json).unwrap(); 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 + } } From fc9264e9aecb4f7b1dbfac3d6d0a69caf5d16770 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sat, 9 May 2026 11:33:40 -0400 Subject: [PATCH 3/3] feat(wasm): add parse_month_year, base32_decode_lenient, guess_mime exports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- crates/relicario-core/src/item_types/totp.rs | 6 +-- crates/relicario-core/src/lib.rs | 2 + crates/relicario-wasm/src/lib.rs | 46 ++++++++++++++++++++ extension/src/wasm.d.ts | 5 +++ 4 files changed, 56 insertions(+), 3 deletions(-) diff --git a/crates/relicario-core/src/item_types/totp.rs b/crates/relicario-core/src/item_types/totp.rs index 00e7b80..74d4d4f 100644 --- a/crates/relicario-core/src/item_types/totp.rs +++ b/crates/relicario-core/src/item_types/totp.rs @@ -10,9 +10,9 @@ use crate::error::{RelicarioError, Result}; /// Steam Mobile Authenticator's 5-character output alphabet. /// 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. +/// +/// Not RFC 4648 — Steam Guard's de-ambiguated alphabet; see [`crate::base32`] +/// for the standard implementation. const STEAM_ALPHABET: &[u8] = b"23456789BCDFGHJKMNPQRTVWXY"; #[derive(Debug, Clone, Serialize, Deserialize, Default)] diff --git a/crates/relicario-core/src/lib.rs b/crates/relicario-core/src/lib.rs index dbdc33a..237a2d1 100644 --- a/crates/relicario-core/src/lib.rs +++ b/crates/relicario-core/src/lib.rs @@ -14,6 +14,8 @@ //! - [`crypto`] — Argon2id KDF (length-prefixed inputs, Zeroizing output) and //! XChaCha20-Poly1305 AEAD with VERSION_BYTE 0x02. //! - [`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. //! - [`item_types`] — Per-type cores (`LoginCore`, `SecureNoteCore`, etc.) and the //! `ItemCore`/`ItemType` enums. diff --git a/crates/relicario-wasm/src/lib.rs b/crates/relicario-wasm/src/lib.rs index 66825e7..f6c6b80 100644 --- a/crates/relicario-wasm/src/lib.rs +++ b/crates/relicario-wasm/src/lib.rs @@ -330,6 +330,32 @@ pub fn embed_image_secret(carrier: &[u8], secret: &[u8]) -> Result, JsEr 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 { + 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, 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}; #[wasm_bindgen] @@ -624,4 +650,24 @@ mod session_tests { // Should fail with a header validation error. 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`. } diff --git a/extension/src/wasm.d.ts b/extension/src/wasm.d.ts index 3643996..2553999 100644 --- a/extension/src/wasm.d.ts +++ b/extension/src/wasm.d.ts @@ -59,6 +59,11 @@ declare module 'relicario-wasm' { export function extract_image_secret(image_bytes: 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 register_device(name: string): {