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 + } }