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>
114 lines
3.8 KiB
Rust
114 lines
3.8 KiB
Rust
//! Time helpers and the `MonthYear` type used for card expiries.
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use crate::error::{RelicarioError, Result};
|
|
|
|
/// Current Unix timestamp in seconds.
|
|
pub fn now_unix() -> i64 {
|
|
chrono::Utc::now().timestamp()
|
|
}
|
|
|
|
/// Month + year (1-12 / e.g. 2026). Used for card expiries.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct MonthYear {
|
|
pub month: u8,
|
|
pub year: u16,
|
|
}
|
|
|
|
impl MonthYear {
|
|
pub fn new(month: u8, year: u16) -> std::result::Result<Self, &'static str> {
|
|
if !(1..=12).contains(&month) {
|
|
return Err("month must be 1..=12");
|
|
}
|
|
if !(2000..=2099).contains(&year) {
|
|
return Err("year must be 2000..=2099");
|
|
}
|
|
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)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn now_unix_is_positive_and_recent() {
|
|
let t = now_unix();
|
|
assert!(t > 1_700_000_000); // after late 2023
|
|
assert!(t < 4_000_000_000); // before 2096
|
|
}
|
|
|
|
#[test]
|
|
fn month_year_constructor_rejects_bad_month() {
|
|
assert!(MonthYear::new(0, 2026).is_err());
|
|
assert!(MonthYear::new(13, 2026).is_err());
|
|
assert!(MonthYear::new(1, 2026).is_ok());
|
|
assert!(MonthYear::new(12, 2026).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn month_year_constructor_rejects_bad_year() {
|
|
assert!(MonthYear::new(1, 1999).is_err());
|
|
assert!(MonthYear::new(1, 2100).is_err());
|
|
assert!(MonthYear::new(1, 2000).is_ok());
|
|
assert!(MonthYear::new(1, 2099).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn month_year_round_trips_through_json() {
|
|
let my = MonthYear::new(7, 2030).unwrap();
|
|
let json = serde_json::to_string(&my).unwrap();
|
|
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
|
|
}
|
|
}
|