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>
This commit is contained in:
@@ -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}"))
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -129,6 +129,11 @@ pub enum RelicarioError {
|
|||||||
/// typed wrappers that delegate to it.
|
/// typed wrappers that delegate to it.
|
||||||
#[error("invalid base32: {0}")]
|
#[error("invalid base32: {0}")]
|
||||||
InvalidBase32(String),
|
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.
|
||||||
|
|||||||
@@ -24,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).
|
||||||
|
|||||||
@@ -46,7 +46,9 @@ 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(crate) mod base32;
|
pub mod base32;
|
||||||
|
|
||||||
|
pub mod mime;
|
||||||
|
|
||||||
pub mod time;
|
pub mod time;
|
||||||
pub use time::{now_unix, MonthYear};
|
pub use time::{now_unix, MonthYear};
|
||||||
|
|||||||
49
crates/relicario-core/src/mime.rs
Normal file
49
crates/relicario-core/src/mime.rs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user