Compare commits
3 Commits
feature/cl
...
feature/cl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc9264e9ae | ||
|
|
03f2a1b58e | ||
|
|
e5d63ab196 |
@@ -36,7 +36,8 @@ pub fn cmd_add(kind: AddKind) -> Result<()> {
|
||||
|
||||
vault.save_item(&item)?;
|
||||
manifest.upsert(&item);
|
||||
vault.after_manifest_change(&manifest)?;
|
||||
vault.save_manifest(&manifest)?;
|
||||
crate::refresh_groups_cache(vault.root(), &manifest);
|
||||
|
||||
let mut paths: Vec<String> = vec![
|
||||
format!("items/{}.enc", item.id.as_str()),
|
||||
|
||||
@@ -72,7 +72,7 @@ pub fn cmd_attach(query: String, file: PathBuf) -> Result<()> {
|
||||
item.modified = now_unix();
|
||||
vault.save_item(&item)?;
|
||||
manifest.upsert(&item);
|
||||
vault.after_manifest_change(&manifest)?;
|
||||
vault.save_manifest(&manifest)?;
|
||||
|
||||
let paths = [
|
||||
format!("items/{}.enc", item.id.as_str()),
|
||||
@@ -161,7 +161,7 @@ pub fn cmd_detach(query: String, aid: String) -> Result<()> {
|
||||
item.modified = now_unix();
|
||||
vault.save_item(&item)?;
|
||||
manifest.upsert(&item);
|
||||
vault.after_manifest_change(&manifest)?;
|
||||
vault.save_manifest(&manifest)?;
|
||||
|
||||
let item_path = format!("items/{}.enc", item.id.as_str());
|
||||
let blob_relpath = format!("attachments/{}/{}.enc", item.id.as_str(), removed.id.as_str());
|
||||
|
||||
@@ -41,7 +41,8 @@ pub fn cmd_edit(query: String, totp_qr: Option<PathBuf>) -> Result<()> {
|
||||
item.modified = now_unix();
|
||||
vault.save_item(&item)?;
|
||||
manifest.upsert(&item);
|
||||
vault.after_manifest_change(&manifest)?;
|
||||
vault.save_manifest(&manifest)?;
|
||||
crate::refresh_groups_cache(vault.root(), &manifest);
|
||||
super::commit_paths(&vault, &format!("edit: {} ({})", crate::helpers::sanitize_for_commit(&item.title), item.id.as_str()),
|
||||
&[&format!("items/{}.enc", item.id.as_str()), "manifest.enc"])?;
|
||||
eprintln!("Updated {}", item.id.as_str());
|
||||
|
||||
@@ -8,7 +8,7 @@ pub fn cmd_get(query: String, show: bool, copy: bool) -> Result<()> {
|
||||
|
||||
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||
let manifest = vault.load_manifest()?;
|
||||
crate::helpers::refresh_groups_cache(vault.root(), &manifest);
|
||||
crate::refresh_groups_cache(vault.root(), &manifest);
|
||||
let entry = super::resolve_query(&manifest, &query)?;
|
||||
let item = vault.load_item(&entry.id)?;
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ fn cmd_import_lastpass(csv_path: PathBuf) -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
vault.after_manifest_change(&manifest)?;
|
||||
vault.save_manifest(&manifest)?;
|
||||
written_paths.push("manifest.enc".into());
|
||||
|
||||
let path_refs: Vec<&str> = written_paths.iter().map(String::as_str).collect();
|
||||
|
||||
@@ -65,7 +65,17 @@ pub fn cmd_init(image: PathBuf, output: PathBuf) -> Result<()> {
|
||||
fs::write(relicario_dir.join("salt"), salt)?;
|
||||
fs::write(
|
||||
relicario_dir.join("params.json"),
|
||||
serde_json::to_string_pretty(&crate::session::ParamsFile::for_new_vault(¶ms))?,
|
||||
serde_json::to_string_pretty(&ParamsFile {
|
||||
format_version: 2,
|
||||
kdf: ParamsKdf {
|
||||
algorithm: "argon2id-v0x13".into(),
|
||||
argon2_m: params.argon2_m,
|
||||
argon2_t: params.argon2_t,
|
||||
argon2_p: params.argon2_p,
|
||||
},
|
||||
aead: "xchacha20poly1305".into(),
|
||||
salt_path: ".relicario/salt".into(),
|
||||
})?,
|
||||
)?;
|
||||
let manifest = Manifest::new();
|
||||
fs::write(root.join("manifest.enc"), encrypt_manifest(&manifest, &master_key)?)?;
|
||||
@@ -96,3 +106,20 @@ pub fn cmd_init(image: PathBuf, output: PathBuf) -> Result<()> {
|
||||
eprintln!(" \u{2192} back this file up somewhere safe; it is your second factor.");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct ParamsFile {
|
||||
format_version: u32,
|
||||
kdf: ParamsKdf,
|
||||
aead: String,
|
||||
salt_path: String,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
struct ParamsKdf {
|
||||
algorithm: String,
|
||||
argon2_m: u32,
|
||||
argon2_t: u32,
|
||||
argon2_p: u32,
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ pub fn cmd_list(
|
||||
|
||||
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||
let manifest = vault.load_manifest()?;
|
||||
crate::helpers::refresh_groups_cache(vault.root(), &manifest);
|
||||
crate::refresh_groups_cache(vault.root(), &manifest);
|
||||
|
||||
let parsed_type: Option<ItemType> = match type_filter.as_deref() {
|
||||
None => None,
|
||||
|
||||
@@ -15,7 +15,8 @@ pub fn cmd_rm(query: String) -> Result<()> {
|
||||
item.soft_delete();
|
||||
vault.save_item(&item)?;
|
||||
manifest.upsert(&item);
|
||||
vault.after_manifest_change(&manifest)?;
|
||||
vault.save_manifest(&manifest)?;
|
||||
crate::refresh_groups_cache(vault.root(), &manifest);
|
||||
super::commit_paths(&vault, &format!("trash: {} ({})", crate::helpers::sanitize_for_commit(&item.title), item.id.as_str()),
|
||||
&[&format!("items/{}.enc", item.id.as_str()), "manifest.enc"])?;
|
||||
eprintln!("Moved to trash: {}", item.title);
|
||||
@@ -32,41 +33,37 @@ pub fn cmd_restore(query: String) -> Result<()> {
|
||||
item.restore();
|
||||
vault.save_item(&item)?;
|
||||
manifest.upsert(&item);
|
||||
vault.after_manifest_change(&manifest)?;
|
||||
vault.save_manifest(&manifest)?;
|
||||
crate::refresh_groups_cache(vault.root(), &manifest);
|
||||
super::commit_paths(&vault, &format!("restore: {} ({})", crate::helpers::sanitize_for_commit(&item.title), item.id.as_str()),
|
||||
&[&format!("items/{}.enc", item.id.as_str()), "manifest.enc"])?;
|
||||
eprintln!("Restored: {}", item.title);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Filesystem-only purge: removes the item.enc, attachments/<id>/, and updates
|
||||
/// the manifest in memory. Returns the relative paths the caller must stage
|
||||
/// via `git rm` after the loop. Does NOT invoke any git commands — the caller
|
||||
/// batches them.
|
||||
pub(super) fn purge_item_filesystem(
|
||||
/// Inner purge: assumes vault is already unlocked and manifest is loaded.
|
||||
/// Caller is responsible for saving the manifest and committing afterwards.
|
||||
pub(super) fn purge_item(
|
||||
vault: &crate::session::UnlockedVault,
|
||||
manifest: &mut relicario_core::Manifest,
|
||||
id: &relicario_core::ItemId,
|
||||
title: &str,
|
||||
) -> Result<Vec<String>> {
|
||||
use std::{fs, io::ErrorKind};
|
||||
) -> Result<()> {
|
||||
use std::fs;
|
||||
|
||||
let item_rel = format!("items/{}.enc", id.as_str());
|
||||
let att_rel = format!("attachments/{}", id.as_str());
|
||||
|
||||
let ignore_missing = |r: std::io::Result<()>| -> Result<()> {
|
||||
match r {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) if e.kind() == ErrorKind::NotFound => Ok(()),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
};
|
||||
ignore_missing(fs::remove_file(vault.item_path(id)))?;
|
||||
ignore_missing(fs::remove_dir_all(vault.root().join("attachments").join(id.as_str())))?;
|
||||
let item_path = vault.item_path(id);
|
||||
if item_path.exists() { fs::remove_file(&item_path)?; }
|
||||
let att_dir = vault.root().join("attachments").join(id.as_str());
|
||||
if att_dir.exists() { fs::remove_dir_all(&att_dir)?; }
|
||||
manifest.remove(id);
|
||||
|
||||
let _ = crate::helpers::git_command(vault.root(), &["rm", "-rf", "--ignore-unmatch",
|
||||
&format!("items/{}.enc", id.as_str()),
|
||||
&format!("attachments/{}", id.as_str()),
|
||||
]).status()?;
|
||||
// Note: caller adds+commits manifest.enc after processing all purges.
|
||||
eprintln!("Purged: {title}");
|
||||
Ok(vec![item_rel, att_rel])
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn cmd_purge(query: String) -> Result<()> {
|
||||
@@ -77,16 +74,12 @@ pub fn cmd_purge(query: String) -> Result<()> {
|
||||
let title = entry.title.clone();
|
||||
let _ = entry;
|
||||
|
||||
let paths = purge_item_filesystem(&vault, &mut manifest, &id, &title)?;
|
||||
vault.after_manifest_change(&manifest)?;
|
||||
purge_item(&vault, &mut manifest, &id, &title)?;
|
||||
vault.save_manifest(&manifest)?;
|
||||
crate::refresh_groups_cache(vault.root(), &manifest);
|
||||
|
||||
let purge_ctx = format!("purge \"{}\" ({})", title, id.as_str());
|
||||
crate::helpers::git_rm(vault.root(), &paths, &format!("{purge_ctx}: git rm"))?;
|
||||
crate::helpers::git_run(
|
||||
vault.root(),
|
||||
&["add", "manifest.enc"],
|
||||
&format!("{purge_ctx}: git add manifest.enc"),
|
||||
)?;
|
||||
crate::helpers::git_run(vault.root(), &["add", "manifest.enc"], &format!("{purge_ctx}: git add manifest.enc"))?;
|
||||
crate::helpers::git_run(
|
||||
vault.root(),
|
||||
&["commit", "-m", &format!("purge: {} ({})", title, id.as_str())],
|
||||
@@ -123,16 +116,13 @@ pub fn cmd_trash_empty() -> Result<()> {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut all_paths: Vec<String> = Vec::new();
|
||||
let purged_count = purgeable.len();
|
||||
let mut purged_titles = Vec::new();
|
||||
for (id, title) in purgeable {
|
||||
let mut paths = purge_item_filesystem(&vault, &mut manifest, &id, &title)?;
|
||||
all_paths.append(&mut paths);
|
||||
purge_item(&vault, &mut manifest, &id, &title)?;
|
||||
purged_titles.push(title);
|
||||
}
|
||||
|
||||
vault.after_manifest_change(&manifest)?;
|
||||
|
||||
crate::helpers::git_rm(vault.root(), &all_paths, "trash empty: git rm")?;
|
||||
vault.save_manifest(&manifest)?;
|
||||
crate::helpers::git_run(
|
||||
vault.root(),
|
||||
&["add", "manifest.enc"],
|
||||
@@ -140,10 +130,10 @@ pub fn cmd_trash_empty() -> Result<()> {
|
||||
)?;
|
||||
crate::helpers::git_run(
|
||||
vault.root(),
|
||||
&["commit", "-m", &format!("trash empty: purged {} item(s)", purged_count)],
|
||||
&["commit", "-m", &format!("trash empty: purged {} item(s)", purged_titles.len())],
|
||||
"trash empty: git commit",
|
||||
)?;
|
||||
|
||||
eprintln!("Emptied trash: {} item(s)", purged_count);
|
||||
eprintln!("Emptied trash: {} item(s)", purged_titles.len());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -86,16 +86,6 @@ pub fn git_run(repo: &Path, args: &[&str], context: &str) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stage `paths` for removal in one `git rm -rf --ignore-unmatch` invocation.
|
||||
/// `--ignore-unmatch` is load-bearing: a previous partial-write crash can
|
||||
/// leave the manifest entry without the corresponding `items/<id>.enc` on
|
||||
/// disk, and we want the rm to succeed regardless.
|
||||
pub fn git_rm(repo: &Path, paths: &[String], context: &str) -> Result<()> {
|
||||
let mut args: Vec<&str> = vec!["rm", "-rf", "--ignore-unmatch"];
|
||||
args.extend(paths.iter().map(String::as_str));
|
||||
git_run(repo, &args, context)
|
||||
}
|
||||
|
||||
/// Format a Unix-seconds timestamp as an ISO-8601 UTC string.
|
||||
/// Audit M11: replaces the old `now_iso8601` helper that actually returned
|
||||
/// a numeric string.
|
||||
@@ -136,24 +126,6 @@ pub fn groups_cache_path(vault_dir: &Path) -> PathBuf {
|
||||
vault_dir.join(".relicario").join("groups.cache")
|
||||
}
|
||||
|
||||
/// Collect all non-empty group names from the manifest and write them to the
|
||||
/// plaintext `groups.cache` file so shell completion can enumerate `--group`
|
||||
/// candidates without prompting for the vault passphrase.
|
||||
///
|
||||
/// Failures are silently swallowed — a missing cache is merely a UX degradation,
|
||||
/// not a correctness problem.
|
||||
pub fn refresh_groups_cache(vault_dir: &Path, manifest: &relicario_core::Manifest) {
|
||||
let mut set = std::collections::BTreeSet::<String>::new();
|
||||
for entry in manifest.items.values() {
|
||||
if let Some(g) = entry.group.as_ref() {
|
||||
if !g.is_empty() {
|
||||
set.insert(g.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = write_groups_cache(vault_dir, &set);
|
||||
}
|
||||
|
||||
/// Write the sorted set of group names to `<vault_dir>/.relicario/groups.cache`,
|
||||
/// one name per line. In debug builds, setting `RELICARIO_NO_GROUPS_CACHE`
|
||||
/// suppresses the write (developer debugging tool). In release builds the env
|
||||
|
||||
@@ -457,6 +457,24 @@ fn main() -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Collect all non-empty group names from the manifest and write them to the
|
||||
/// plaintext `groups.cache` file so shell completion can enumerate `--group`
|
||||
/// candidates without prompting for the vault passphrase.
|
||||
///
|
||||
/// Failures are silently swallowed — a missing cache is merely a UX degradation,
|
||||
/// not a correctness problem.
|
||||
pub(crate) fn refresh_groups_cache(vault_dir: &std::path::Path, manifest: &relicario_core::Manifest) {
|
||||
let mut set = std::collections::BTreeSet::<String>::new();
|
||||
for entry in manifest.items.values() {
|
||||
if let Some(g) = entry.group.as_ref() {
|
||||
if !g.is_empty() {
|
||||
set.insert(g.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = helpers::write_groups_cache(vault_dir, &set);
|
||||
}
|
||||
|
||||
/// Check for test passphrase override (debug builds only; stripped from release).
|
||||
#[cfg(debug_assertions)]
|
||||
pub(crate) fn test_passphrase_override() -> Option<String> {
|
||||
|
||||
@@ -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<relicario_core::MonthYear> {
|
||||
// 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::<u16>().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<MonthYear> {
|
||||
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<Vec<u8>> {
|
||||
let cleaned: String = s.chars()
|
||||
.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}"))
|
||||
Ok(relicario_core::base32::decode_rfc4648_lenient(s)?)
|
||||
}
|
||||
|
||||
@@ -69,15 +69,9 @@ impl UnlockedVault {
|
||||
Ok(decrypt_manifest(&bytes, &self.master_key)?)
|
||||
}
|
||||
|
||||
/// Save the manifest and refresh the plaintext groups.cache. This is the
|
||||
/// canonical "I just mutated the manifest" funnel — every command that
|
||||
/// changes the manifest goes through this method, so cache freshness is
|
||||
/// a compile-time invariant rather than a discipline rule.
|
||||
pub fn after_manifest_change(&self, manifest: &Manifest) -> Result<()> {
|
||||
pub fn save_manifest(&self, manifest: &Manifest) -> Result<()> {
|
||||
let bytes = encrypt_manifest(manifest, &self.master_key)?;
|
||||
atomic_write(&self.manifest_path(), &bytes)?;
|
||||
crate::helpers::refresh_groups_cache(&self.root, manifest);
|
||||
Ok(())
|
||||
atomic_write(&self.manifest_path(), &bytes)
|
||||
}
|
||||
|
||||
pub fn load_settings(&self) -> Result<VaultSettings> {
|
||||
@@ -113,52 +107,17 @@ fn read_salt(root: &Path) -> Result<[u8; 32]> {
|
||||
Ok(salt)
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
pub(crate) struct ParamsFile {
|
||||
pub format_version: u32,
|
||||
pub kdf: ParamsKdf,
|
||||
pub aead: String,
|
||||
pub salt_path: String,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub(crate) struct ParamsKdf {
|
||||
pub algorithm: String,
|
||||
pub argon2_m: u32,
|
||||
pub argon2_t: u32,
|
||||
pub argon2_p: u32,
|
||||
}
|
||||
|
||||
impl ParamsFile {
|
||||
pub fn for_new_vault(params: &KdfParams) -> Self {
|
||||
Self {
|
||||
format_version: 2,
|
||||
kdf: ParamsKdf {
|
||||
algorithm: "argon2id-v0x13".into(),
|
||||
argon2_m: params.argon2_m,
|
||||
argon2_t: params.argon2_t,
|
||||
argon2_p: params.argon2_p,
|
||||
},
|
||||
aead: "xchacha20poly1305".into(),
|
||||
salt_path: ".relicario/salt".into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_kdf_params(&self) -> KdfParams {
|
||||
KdfParams {
|
||||
argon2_m: self.kdf.argon2_m,
|
||||
argon2_t: self.kdf.argon2_t,
|
||||
argon2_p: self.kdf.argon2_p,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn read_params(root: &Path) -> Result<KdfParams> {
|
||||
// params.json layout: { "format_version": 2, "kdf": { "argon2_m": ..., ... }, ... }
|
||||
// We extract only the "kdf" sub-object and deserialize it as KdfParams.
|
||||
#[derive(serde::Deserialize)]
|
||||
struct ParamsFile {
|
||||
kdf: KdfParams,
|
||||
}
|
||||
let s = fs::read_to_string(root.join(".relicario").join("params.json"))
|
||||
.context("failed to read .relicario/params.json")?;
|
||||
let pf: ParamsFile = serde_json::from_str(&s).context("failed to parse params.json")?;
|
||||
Ok(pf.to_kdf_params())
|
||||
Ok(pf.kdf)
|
||||
}
|
||||
|
||||
/// Locate the reference image path via `RELICARIO_IMAGE` env var or interactive prompt.
|
||||
@@ -190,78 +149,3 @@ fn atomic_write(path: &Path, data: &[u8]) -> Result<()> {
|
||||
fs::rename(&tmp, path).with_context(|| format!("failed to rename {}", path.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const FIXTURE: &str = r#"{
|
||||
"format_version": 2,
|
||||
"kdf": {
|
||||
"algorithm": "argon2id-v0x13",
|
||||
"argon2_m": 65536,
|
||||
"argon2_t": 3,
|
||||
"argon2_p": 4
|
||||
},
|
||||
"aead": "xchacha20poly1305",
|
||||
"salt_path": ".relicario/salt"
|
||||
}"#;
|
||||
|
||||
#[test]
|
||||
fn params_file_round_trips_current_layout() {
|
||||
let pf: ParamsFile = serde_json::from_str(FIXTURE).expect("parse fixture");
|
||||
assert_eq!(pf.format_version, 2);
|
||||
assert_eq!(pf.kdf.algorithm, "argon2id-v0x13");
|
||||
assert_eq!(pf.kdf.argon2_m, 65536);
|
||||
assert_eq!(pf.kdf.argon2_t, 3);
|
||||
assert_eq!(pf.kdf.argon2_p, 4);
|
||||
assert_eq!(pf.aead, "xchacha20poly1305");
|
||||
assert_eq!(pf.salt_path, ".relicario/salt");
|
||||
|
||||
let kdf = pf.to_kdf_params();
|
||||
assert_eq!(kdf.argon2_m, 65536);
|
||||
assert_eq!(kdf.argon2_t, 3);
|
||||
assert_eq!(kdf.argon2_p, 4);
|
||||
|
||||
let serialized = serde_json::to_string(&pf).expect("re-serialize");
|
||||
let pf2: ParamsFile = serde_json::from_str(&serialized).expect("parse re-serialized");
|
||||
assert_eq!(pf2.format_version, 2);
|
||||
assert_eq!(pf2.kdf.algorithm, "argon2id-v0x13");
|
||||
assert_eq!(pf2.kdf.argon2_m, 65536);
|
||||
assert_eq!(pf2.kdf.argon2_t, 3);
|
||||
assert_eq!(pf2.kdf.argon2_p, 4);
|
||||
assert_eq!(pf2.aead, "xchacha20poly1305");
|
||||
assert_eq!(pf2.salt_path, ".relicario/salt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn for_new_vault_produces_expected_shape() {
|
||||
let params = KdfParams { argon2_m: 65536, argon2_t: 3, argon2_p: 4 };
|
||||
let pf = ParamsFile::for_new_vault(¶ms);
|
||||
let v = serde_json::to_value(&pf).expect("to_value");
|
||||
assert_eq!(v["format_version"], 2);
|
||||
assert_eq!(v["kdf"]["algorithm"], "argon2id-v0x13");
|
||||
assert_eq!(v["kdf"]["argon2_m"], 65536);
|
||||
assert_eq!(v["kdf"]["argon2_t"], 3);
|
||||
assert_eq!(v["kdf"]["argon2_p"], 4);
|
||||
assert_eq!(v["aead"], "xchacha20poly1305");
|
||||
assert_eq!(v["salt_path"], ".relicario/salt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn after_manifest_change_writes_manifest_and_groups_cache() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let root = dir.path().to_path_buf();
|
||||
std::fs::create_dir_all(root.join(".relicario")).unwrap();
|
||||
std::fs::create_dir_all(root.join("items")).unwrap();
|
||||
let vault = UnlockedVault {
|
||||
root: root.clone(),
|
||||
master_key: Zeroizing::new([0u8; 32]),
|
||||
};
|
||||
let manifest = Manifest::new();
|
||||
|
||||
vault.after_manifest_change(&manifest).unwrap();
|
||||
assert!(root.join("manifest.enc").exists());
|
||||
assert!(root.join(".relicario/groups.cache").exists());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,72 +109,6 @@ fn rm_restore_purge_cycle() {
|
||||
assert!(!String::from_utf8(out.stdout).unwrap().contains("target"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trash_empty_batches_into_one_commit() {
|
||||
let v = TestVault::init();
|
||||
|
||||
// Add 3 items.
|
||||
for title in ["alpha", "bravo", "charlie"] {
|
||||
let out = v.run(&[
|
||||
"add", "login",
|
||||
"--title", title,
|
||||
"--username", "u",
|
||||
"--password", "p",
|
||||
]);
|
||||
assert!(out.status.success(), "add {title} failed");
|
||||
}
|
||||
|
||||
// Soft-delete all 3.
|
||||
for title in ["alpha", "bravo", "charlie"] {
|
||||
let out = v.run(&["rm", title]);
|
||||
assert!(out.status.success(), "rm {title} failed");
|
||||
}
|
||||
|
||||
// Set retention to 0 days so the recently-trashed items become purgeable
|
||||
// (should_purge: now - trashed_at > 0 * 86400 = 0).
|
||||
let out = v.run(&["settings", "trash-retention", "--days", "0"]);
|
||||
assert!(out.status.success(), "settings trash-retention failed");
|
||||
|
||||
// should_purge uses strict > on (now - trashed_at), so equal-second
|
||||
// timestamps don't qualify.
|
||||
std::thread::sleep(std::time::Duration::from_secs(1));
|
||||
|
||||
// Count commits before.
|
||||
let before = std::process::Command::new("git")
|
||||
.args(["rev-list", "--count", "HEAD"])
|
||||
.current_dir(v.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
let before_count: u32 = String::from_utf8(before.stdout).unwrap().trim().parse().unwrap();
|
||||
|
||||
// Run trash empty.
|
||||
let out = v.run(&["trash", "empty"]);
|
||||
assert!(out.status.success(), "trash empty failed: stderr={}",
|
||||
String::from_utf8_lossy(&out.stderr));
|
||||
|
||||
// Count commits after.
|
||||
let after = std::process::Command::new("git")
|
||||
.args(["rev-list", "--count", "HEAD"])
|
||||
.current_dir(v.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
let after_count: u32 = String::from_utf8(after.stdout).unwrap().trim().parse().unwrap();
|
||||
|
||||
assert_eq!(
|
||||
after_count - before_count, 1,
|
||||
"trash empty should fire exactly one commit; before={before_count} after={after_count}"
|
||||
);
|
||||
|
||||
// The remaining `list --trashed` should be empty.
|
||||
let out = v.run(&["list", "--trashed"]);
|
||||
let stdout = String::from_utf8(out.stdout).unwrap();
|
||||
let stderr = String::from_utf8(out.stderr).unwrap();
|
||||
assert!(
|
||||
!stdout.contains("alpha") && !stdout.contains("bravo") && !stdout.contains("charlie"),
|
||||
"items still in trashed list: stdout={stdout} stderr={stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate_random_and_bip39() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
|
||||
132
crates/relicario-core/src/base32.rs
Normal file
132
crates/relicario-core/src/base32.rs
Normal file
@@ -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<Vec<u8>> {
|
||||
let cleaned: String = s
|
||||
.chars()
|
||||
.filter(|c| !c.is_whitespace())
|
||||
.collect::<String>()
|
||||
.to_ascii_uppercase();
|
||||
let trimmed = cleaned.trim_end_matches('=');
|
||||
let mut out: Vec<u8> = 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<u8> = (0u8..=255).collect();
|
||||
let encoded = encode_rfc4648(&bytes);
|
||||
assert_eq!(decode_rfc4648_lenient(&encoded).unwrap(), bytes);
|
||||
}
|
||||
}
|
||||
@@ -123,6 +123,17 @@ 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),
|
||||
|
||||
/// 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.
|
||||
|
||||
@@ -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<Vec<u8>> {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -244,7 +244,7 @@ fn serialize_history_value(value: &FieldValue) -> Result<Zeroizing<String>> {
|
||||
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<Zeroizing<String>> {
|
||||
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::*;
|
||||
|
||||
@@ -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)]
|
||||
@@ -21,6 +24,14 @@ pub struct TotpCore {
|
||||
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)]
|
||||
pub struct TotpConfig {
|
||||
/// Raw bytes of the TOTP secret (decoded from base32 when imported).
|
||||
|
||||
@@ -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.
|
||||
@@ -46,6 +48,10 @@ pub use crypto::{decrypt, derive_master_key, encrypt, KdfParams, VERSION_BYTE};
|
||||
pub mod ids;
|
||||
pub use ids::{AttachmentId, FieldId, ItemId};
|
||||
|
||||
pub mod base32;
|
||||
|
||||
pub mod mime;
|
||||
|
||||
pub mod time;
|
||||
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 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<Self, &'static str> {
|
||||
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");
|
||||
}
|
||||
@@ -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<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)]
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()))
|
||||
}
|
||||
|
||||
// ── 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};
|
||||
|
||||
#[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`.
|
||||
}
|
||||
|
||||
5
extension/src/wasm.d.ts
vendored
5
extension/src/wasm.d.ts
vendored
@@ -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): {
|
||||
|
||||
Reference in New Issue
Block a user