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>
This commit is contained in:
adlee-was-taken
2026-05-09 11:33:40 -04:00
parent 03f2a1b58e
commit fc9264e9ae
4 changed files with 56 additions and 3 deletions

View File

@@ -10,9 +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` /// Not RFC 4648 — Steam Guard's de-ambiguated alphabet; see [`crate::base32`]
// for the standard implementation. /// 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)]

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.

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): {