Merge feature/fullscreen-ux-phase-2a: smart-input affordances
Phase 2A of the fullscreen UX redesign — 8 form-level smart-input affordances (URL fill-from-tab + hostname chip, group autocomplete, password reveal + strength bar, TOTP live preview + QR decode, notes monospace toggle), shared between popup and fullscreen vault tabs via the new extension/src/shared/form-affordances/ module set. CLI parity: - relicario rate <passphrase> (zxcvbn score / guess estimate) - relicario completions <SHELL> (bash/zsh/fish via clap_complete) - --group <TAB> dynamic enumeration via .relicario/groups.cache (plaintext leak surface; opt out with RELICARIO_NO_GROUPS_CACHE=1) - --totp-qr <path> on add login + edit (rqrr decode) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -25,10 +25,13 @@ zeroize = "1"
|
||||
url = "2"
|
||||
data-encoding = "2"
|
||||
tar = { version = "0.4", default-features = false }
|
||||
clap_complete = "4"
|
||||
image = { version = "0.25", default-features = false, features = ["jpeg", "png"] }
|
||||
rqrr = "0.7"
|
||||
|
||||
[dev-dependencies]
|
||||
assert_cmd = "2"
|
||||
predicates = "3"
|
||||
tempfile = "3"
|
||||
image = { version = "0.25", default-features = false, features = ["jpeg"] }
|
||||
qrcode = "0.14"
|
||||
serde_json = "1"
|
||||
|
||||
@@ -83,6 +83,66 @@ pub fn humanize_age(seconds: i64) -> String {
|
||||
|
||||
fn plural(n: i64) -> &'static str { if n == 1 { "" } else { "s" } }
|
||||
|
||||
/// Path to the plaintext `groups.cache` file used by shell completion to
|
||||
/// enumerate `--group <TAB>` candidates without unlocking the vault.
|
||||
///
|
||||
/// **Plaintext leak:** group names land on disk in cleartext alongside the
|
||||
/// vault directory. This is intentional — the file feeds shell completion,
|
||||
/// which cannot prompt for a passphrase. Set `RELICARIO_NO_GROUPS_CACHE=1`
|
||||
/// to suppress the write.
|
||||
pub fn groups_cache_path(vault_dir: &Path) -> PathBuf {
|
||||
vault_dir.join(".relicario").join("groups.cache")
|
||||
}
|
||||
|
||||
/// Write the sorted set of group names to `<vault_dir>/.relicario/groups.cache`,
|
||||
/// one name per line. A no-op if `RELICARIO_NO_GROUPS_CACHE` is set.
|
||||
pub fn write_groups_cache(
|
||||
vault_dir: &Path,
|
||||
groups: &std::collections::BTreeSet<String>,
|
||||
) -> std::io::Result<()> {
|
||||
if std::env::var_os("RELICARIO_NO_GROUPS_CACHE").is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
let path = groups_cache_path(vault_dir);
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let mut body = String::new();
|
||||
for g in groups {
|
||||
body.push_str(g);
|
||||
body.push('\n');
|
||||
}
|
||||
std::fs::write(path, body)
|
||||
}
|
||||
|
||||
/// Decode a QR image at `path`. Returns the otpauth secret (base32) if the
|
||||
/// QR decodes to an `otpauth://...` URI with a `secret` query param.
|
||||
pub fn decode_totp_qr(path: &std::path::Path) -> anyhow::Result<String> {
|
||||
let img = image::open(path)
|
||||
.map_err(|e| anyhow::anyhow!("failed to read image: {e}"))?
|
||||
.to_luma8();
|
||||
let mut prepared = rqrr::PreparedImage::prepare(img);
|
||||
let grids = prepared.detect_grids();
|
||||
let grid = grids
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or_else(|| anyhow::anyhow!("no QR code found in image"))?;
|
||||
let (_meta, content) = grid
|
||||
.decode()
|
||||
.map_err(|e| anyhow::anyhow!("QR decode failed: {e}"))?;
|
||||
if !content.starts_with("otpauth://") {
|
||||
return Err(anyhow::anyhow!("not a TOTP URI (expected otpauth://...)"));
|
||||
}
|
||||
let parsed =
|
||||
url::Url::parse(&content).map_err(|e| anyhow::anyhow!("invalid otpauth URI: {e}"))?;
|
||||
let secret = parsed
|
||||
.query_pairs()
|
||||
.find(|(k, _)| k == "secret")
|
||||
.map(|(_, v)| v.to_string())
|
||||
.ok_or_else(|| anyhow::anyhow!("otpauth URI missing `secret` parameter"))?;
|
||||
Ok(secret)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -8,7 +8,8 @@ mod session;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use clap::{Parser, Subcommand};
|
||||
use clap::{CommandFactory, Parser, Subcommand};
|
||||
use clap_complete::{generate, Shell};
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(
|
||||
@@ -65,7 +66,12 @@ enum Commands {
|
||||
},
|
||||
|
||||
/// Edit an item interactively.
|
||||
Edit { query: String },
|
||||
Edit {
|
||||
query: String,
|
||||
/// Decode an `otpauth://` QR image to set the TOTP secret (login items only).
|
||||
#[arg(long, value_name = "PATH")]
|
||||
totp_qr: Option<PathBuf>,
|
||||
},
|
||||
|
||||
/// View captured field history for an item. Values are masked by
|
||||
/// default; pass `--show` to reveal them.
|
||||
@@ -163,6 +169,31 @@ enum Commands {
|
||||
|
||||
/// Lock the vault (no-op in CLI; present for UX parity with the extension).
|
||||
Lock,
|
||||
|
||||
/// Emit a shell completion script for the given shell.
|
||||
///
|
||||
/// For `--group <TAB>` autocomplete, the bash/zsh/fish scripts read
|
||||
/// the plaintext `${RELICARIO_VAULT}/.relicario/groups.cache` file,
|
||||
/// which the CLI refreshes on every manifest read. Set
|
||||
/// `RELICARIO_NO_GROUPS_CACHE=1` to opt out of the cache (completion
|
||||
/// will fall back to no value enumeration).
|
||||
///
|
||||
/// Pipe stdout to your shell's completion location (e.g.
|
||||
/// `relicario completions bash > /etc/bash_completion.d/relicario`).
|
||||
Completions {
|
||||
#[arg(value_enum)]
|
||||
shell: Shell,
|
||||
},
|
||||
|
||||
/// Rate a passphrase with zxcvbn — prints score (0-4) and estimated
|
||||
/// guesses. Informational only; does not gate vault operations.
|
||||
///
|
||||
/// Pass `-` as the argument to read one line from stdin instead, which
|
||||
/// keeps the passphrase out of shell history.
|
||||
Rate {
|
||||
/// Passphrase to score, or `-` to read from stdin.
|
||||
passphrase: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
@@ -177,6 +208,8 @@ enum AddKind {
|
||||
#[arg(long)] group: Option<String>,
|
||||
#[arg(long, value_delimiter = ',')] tags: Vec<String>,
|
||||
#[arg(long)] favorite: bool,
|
||||
/// Decode an `otpauth://` QR image to fill the TOTP secret.
|
||||
#[arg(long, value_name = "PATH")] totp_qr: Option<PathBuf>,
|
||||
},
|
||||
SecureNote {
|
||||
#[arg(long)] title: Option<String>,
|
||||
@@ -334,7 +367,7 @@ fn main() -> Result<()> {
|
||||
Commands::Add { kind } => cmd_add(kind),
|
||||
Commands::Get { query, show, copy } => cmd_get(query, show, copy),
|
||||
Commands::List { r#type, group, tag, trashed } => cmd_list(r#type, group, tag, trashed),
|
||||
Commands::Edit { query } => cmd_edit(query),
|
||||
Commands::Edit { query, totp_qr } => cmd_edit(query, totp_qr),
|
||||
Commands::History { query, show, field } => cmd_history(query, show, field),
|
||||
Commands::Rm { query } => cmd_rm(query),
|
||||
Commands::Restore { query } => cmd_restore(query),
|
||||
@@ -354,9 +387,33 @@ fn main() -> Result<()> {
|
||||
Commands::Status => cmd_status(),
|
||||
Commands::Device { action } => cmd_device(action),
|
||||
Commands::Lock => { eprintln!("no cached session to lock"); Ok(()) }
|
||||
Commands::Completions { shell } => {
|
||||
let mut cmd = Cli::command();
|
||||
generate(shell, &mut cmd, "relicario", &mut std::io::stdout());
|
||||
Ok(())
|
||||
}
|
||||
Commands::Rate { passphrase } => cmd_rate(passphrase),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
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);
|
||||
}
|
||||
|
||||
/// `rpassword::prompt_password` wrapper that honours `RELICARIO_TEST_ITEM_SECRET`
|
||||
/// for integration-test use (rpassword reads /dev/tty by default, which is
|
||||
/// unavailable in assert_cmd-spawned children).
|
||||
@@ -476,8 +533,8 @@ fn cmd_add(kind: AddKind) -> Result<()> {
|
||||
let mut manifest = vault.load_manifest()?;
|
||||
|
||||
let item = match kind {
|
||||
AddKind::Login { title, username, url, password_prompt, password, group, tags, favorite } =>
|
||||
build_login_item(title, username, url, password_prompt, password, group, tags, favorite)?,
|
||||
AddKind::Login { title, username, url, password_prompt, password, group, tags, favorite, totp_qr } =>
|
||||
build_login_item(title, username, url, password_prompt, password, group, tags, favorite, totp_qr)?,
|
||||
AddKind::SecureNote { title, body_prompt, group, tags } =>
|
||||
build_secure_note_item(title, body_prompt, group, tags)?,
|
||||
AddKind::Identity { title, full_name, email, phone, date_of_birth, group, tags } =>
|
||||
@@ -495,6 +552,7 @@ fn cmd_add(kind: AddKind) -> Result<()> {
|
||||
vault.save_item(&item)?;
|
||||
manifest.upsert(&item);
|
||||
vault.save_manifest(&manifest)?;
|
||||
refresh_groups_cache(vault.root(), &manifest);
|
||||
|
||||
let mut paths: Vec<String> = vec![
|
||||
format!("items/{}.enc", item.id.as_str()),
|
||||
@@ -525,8 +583,9 @@ fn build_login_item(
|
||||
group: Option<String>,
|
||||
tags: Vec<String>,
|
||||
favorite: bool,
|
||||
totp_qr: Option<PathBuf>,
|
||||
) -> Result<relicario_core::Item> {
|
||||
use relicario_core::item_types::LoginCore;
|
||||
use relicario_core::item_types::{LoginCore, TotpAlgorithm, TotpConfig, TotpKind};
|
||||
use relicario_core::{Item, ItemCore};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
@@ -544,8 +603,21 @@ fn build_login_item(
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let totp = if let Some(path) = totp_qr {
|
||||
let secret_b32 = crate::helpers::decode_totp_qr(&path)?;
|
||||
let secret_bytes = base32_decode_lenient(&secret_b32)?;
|
||||
Some(TotpConfig {
|
||||
secret: Zeroizing::new(secret_bytes),
|
||||
algorithm: TotpAlgorithm::Sha1,
|
||||
digits: 6,
|
||||
period_seconds: 30,
|
||||
kind: TotpKind::Totp,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut item = Item::new(title, ItemCore::Login(LoginCore {
|
||||
username, password, url: parsed_url, totp: None,
|
||||
username, password, url: parsed_url, totp,
|
||||
}));
|
||||
item.group = group;
|
||||
item.tags = tags;
|
||||
@@ -835,6 +907,7 @@ fn cmd_get(query: String, show: bool, copy: bool) -> Result<()> {
|
||||
|
||||
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||
let manifest = vault.load_manifest()?;
|
||||
refresh_groups_cache(vault.root(), &manifest);
|
||||
let entry = resolve_query(&manifest, &query)?;
|
||||
let item = vault.load_item(&entry.id)?;
|
||||
|
||||
@@ -852,6 +925,13 @@ fn cmd_get(query: String, show: bool, copy: bool) -> Result<()> {
|
||||
ItemCore::Login(l) => {
|
||||
if let Some(u) = &l.username { println!("Username: {u}"); }
|
||||
if let Some(u) = &l.url { println!("URL: {u}"); }
|
||||
if let Some(t) = &l.totp {
|
||||
if show {
|
||||
println!("TOTP: {}", data_encoding::BASE32.encode(&*t.secret));
|
||||
} else {
|
||||
println!("TOTP: **** (use --show to reveal)");
|
||||
}
|
||||
}
|
||||
if let Some(p) = &l.password { Some(p.clone()) } else { None }
|
||||
}
|
||||
ItemCore::SecureNote(n) => {
|
||||
@@ -952,6 +1032,7 @@ fn cmd_list(
|
||||
|
||||
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||
let manifest = vault.load_manifest()?;
|
||||
refresh_groups_cache(vault.root(), &manifest);
|
||||
|
||||
let parsed_type: Option<ItemType> = match type_filter.as_deref() {
|
||||
None => None,
|
||||
@@ -990,7 +1071,7 @@ fn cmd_list(
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn cmd_edit(query: String) -> Result<()> {
|
||||
fn cmd_edit(query: String, totp_qr: Option<PathBuf>) -> Result<()> {
|
||||
use relicario_core::time::now_unix;
|
||||
use relicario_core::ItemCore;
|
||||
|
||||
@@ -1012,7 +1093,7 @@ fn cmd_edit(query: String) -> Result<()> {
|
||||
|
||||
let history = &mut item.field_history;
|
||||
match &mut item.core {
|
||||
ItemCore::Login(l) => edit_login(l, history)?,
|
||||
ItemCore::Login(l) => edit_login(l, history, totp_qr)?,
|
||||
ItemCore::SecureNote(n) => edit_secure_note(n, history)?,
|
||||
ItemCore::Identity(i) => edit_identity(i)?,
|
||||
ItemCore::Card(c) => edit_card(c, history)?,
|
||||
@@ -1025,6 +1106,7 @@ fn cmd_edit(query: String) -> Result<()> {
|
||||
vault.save_item(&item)?;
|
||||
manifest.upsert(&item);
|
||||
vault.save_manifest(&manifest)?;
|
||||
refresh_groups_cache(vault.root(), &manifest);
|
||||
commit_paths(&vault, &format!("edit: {} ({})", item.title, item.id.as_str()),
|
||||
&[&format!("items/{}.enc", item.id.as_str()), "manifest.enc"])?;
|
||||
eprintln!("Updated {}", item.id.as_str());
|
||||
@@ -1040,7 +1122,12 @@ type FieldHistory = std::collections::HashMap<
|
||||
Vec<relicario_core::item::FieldHistoryEntry>,
|
||||
>;
|
||||
|
||||
fn edit_login(l: &mut relicario_core::item_types::LoginCore, history: &mut FieldHistory) -> Result<()> {
|
||||
fn edit_login(
|
||||
l: &mut relicario_core::item_types::LoginCore,
|
||||
history: &mut FieldHistory,
|
||||
totp_qr: Option<PathBuf>,
|
||||
) -> Result<()> {
|
||||
use relicario_core::item_types::{TotpAlgorithm, TotpConfig, TotpKind};
|
||||
use zeroize::Zeroizing;
|
||||
if let Some(v) = prompt_keep_opt("Username", l.username.as_deref())? { l.username = Some(v); }
|
||||
if let Some(v) = prompt_keep_opt("URL", l.url.as_ref().map(|u| u.as_str()))? {
|
||||
@@ -1053,6 +1140,18 @@ fn edit_login(l: &mut relicario_core::item_types::LoginCore, history: &mut Field
|
||||
push_history(history, "login_password", Zeroizing::new(old_pw.as_str().to_string()));
|
||||
}
|
||||
}
|
||||
if let Some(path) = totp_qr {
|
||||
let secret_b32 = crate::helpers::decode_totp_qr(&path)?;
|
||||
let secret_bytes = base32_decode_lenient(&secret_b32)?;
|
||||
l.totp = Some(TotpConfig {
|
||||
secret: Zeroizing::new(secret_bytes),
|
||||
algorithm: TotpAlgorithm::Sha1,
|
||||
digits: 6,
|
||||
period_seconds: 30,
|
||||
kind: TotpKind::Totp,
|
||||
});
|
||||
eprintln!("TOTP secret set from QR image.");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1224,6 +1323,7 @@ fn cmd_rm(query: String) -> Result<()> {
|
||||
vault.save_item(&item)?;
|
||||
manifest.upsert(&item);
|
||||
vault.save_manifest(&manifest)?;
|
||||
refresh_groups_cache(vault.root(), &manifest);
|
||||
commit_paths(&vault, &format!("trash: {} ({})", item.title, item.id.as_str()),
|
||||
&[&format!("items/{}.enc", item.id.as_str()), "manifest.enc"])?;
|
||||
eprintln!("Moved to trash: {}", item.title);
|
||||
@@ -1241,6 +1341,7 @@ fn cmd_restore(query: String) -> Result<()> {
|
||||
vault.save_item(&item)?;
|
||||
manifest.upsert(&item);
|
||||
vault.save_manifest(&manifest)?;
|
||||
refresh_groups_cache(vault.root(), &manifest);
|
||||
commit_paths(&vault, &format!("restore: {} ({})", item.title, item.id.as_str()),
|
||||
&[&format!("items/{}.enc", item.id.as_str()), "manifest.enc"])?;
|
||||
eprintln!("Restored: {}", item.title);
|
||||
@@ -1282,6 +1383,7 @@ fn cmd_purge(query: String) -> Result<()> {
|
||||
|
||||
purge_item(&vault, &mut manifest, &id, &title)?;
|
||||
vault.save_manifest(&manifest)?;
|
||||
refresh_groups_cache(vault.root(), &manifest);
|
||||
|
||||
let status = crate::helpers::git_command(vault.root(), &["add", "manifest.enc"]).status()?;
|
||||
if !status.success() { anyhow::bail!("git add manifest.enc failed"); }
|
||||
@@ -2134,3 +2236,28 @@ struct ParamsKdf {
|
||||
argon2_t: u32,
|
||||
argon2_p: u32,
|
||||
}
|
||||
|
||||
fn cmd_rate(passphrase: String) -> Result<()> {
|
||||
let pw: String = if passphrase == "-" {
|
||||
use std::io::BufRead;
|
||||
let stdin = std::io::stdin();
|
||||
let mut line = String::new();
|
||||
stdin.lock().read_line(&mut line)?;
|
||||
line.trim_end_matches(&['\r', '\n'][..]).to_string()
|
||||
} else {
|
||||
passphrase
|
||||
};
|
||||
let est = relicario_core::generators::rate_passphrase(&pw);
|
||||
let label = match est.score {
|
||||
0 => "very weak",
|
||||
1 => "weak",
|
||||
2 => "fair",
|
||||
3 => "good",
|
||||
4 => "strong",
|
||||
_ => "?",
|
||||
};
|
||||
println!("score: {}/4 ({})", est.score, label);
|
||||
println!("guesses: ~10^{:.1}", est.guesses_log10);
|
||||
println!("note: init requires score ≥ 3 (see `relicario init`)");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
210
crates/relicario-cli/tests/smart_inputs.rs
Normal file
210
crates/relicario-cli/tests/smart_inputs.rs
Normal file
@@ -0,0 +1,210 @@
|
||||
mod common;
|
||||
|
||||
use assert_cmd::Command;
|
||||
use predicates::prelude::PredicateBooleanExt;
|
||||
use predicates::str::contains;
|
||||
|
||||
#[test]
|
||||
fn completions_bash_emits_script() {
|
||||
Command::cargo_bin("relicario").unwrap()
|
||||
.args(["completions", "bash"])
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("_relicario"))
|
||||
.stdout(contains("complete -F"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completions_zsh_emits_script() {
|
||||
Command::cargo_bin("relicario").unwrap()
|
||||
.args(["completions", "zsh"])
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("#compdef relicario"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completions_fish_emits_script() {
|
||||
Command::cargo_bin("relicario").unwrap()
|
||||
.args(["completions", "fish"])
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("complete -c relicario"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_command_refreshes_groups_cache() {
|
||||
let v = common::TestVault::init();
|
||||
|
||||
let out = v.run(&[
|
||||
"add", "login",
|
||||
"--title", "T",
|
||||
"--username", "u",
|
||||
"--group", "work",
|
||||
"--password", "hunter2",
|
||||
]);
|
||||
assert!(out.status.success(), "add failed: {:?}", out);
|
||||
|
||||
let out = v.run(&["list"]);
|
||||
assert!(out.status.success(), "list failed: {:?}", out);
|
||||
|
||||
let cache_path = v.path().join(".relicario/groups.cache");
|
||||
let cache = std::fs::read_to_string(&cache_path)
|
||||
.unwrap_or_else(|e| panic!("groups.cache not found at {}: {e}", cache_path.display()));
|
||||
assert!(
|
||||
cache.lines().any(|l| l == "work"),
|
||||
"expected 'work' in groups.cache, got: {cache:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_groups_cache_env_var_suppresses_write() {
|
||||
use std::process::{Command as StdCommand, Stdio};
|
||||
use assert_cmd::cargo::CommandCargoExt as _;
|
||||
|
||||
let v = common::TestVault::init();
|
||||
|
||||
// Add with the env var set so no cache is created by add either.
|
||||
let out = StdCommand::cargo_bin("relicario").unwrap()
|
||||
.current_dir(v.path())
|
||||
.env("RELICARIO_IMAGE", &v.reference_image)
|
||||
.env("RELICARIO_TEST_PASSPHRASE", &v.passphrase)
|
||||
.env("RELICARIO_NO_GROUPS_CACHE", "1")
|
||||
.args([
|
||||
"add", "login",
|
||||
"--title", "T2",
|
||||
"--username", "u",
|
||||
"--group", "personal",
|
||||
"--password", "hunter2",
|
||||
])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(out.status.success(), "add failed: {:?}", out);
|
||||
|
||||
// Run list with RELICARIO_NO_GROUPS_CACHE=1 — cache must NOT be written.
|
||||
let out = StdCommand::cargo_bin("relicario").unwrap()
|
||||
.current_dir(v.path())
|
||||
.env("RELICARIO_IMAGE", &v.reference_image)
|
||||
.env("RELICARIO_TEST_PASSPHRASE", &v.passphrase)
|
||||
.env("RELICARIO_NO_GROUPS_CACHE", "1")
|
||||
.args(["list"])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(out.status.success(), "list failed: {:?}", out);
|
||||
|
||||
let cache_path = v.path().join(".relicario/groups.cache");
|
||||
assert!(
|
||||
!cache_path.exists(),
|
||||
"groups.cache should not exist when RELICARIO_NO_GROUPS_CACHE=1"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rate_strong_passphrase_prints_score_and_guesses() {
|
||||
Command::cargo_bin("relicario").unwrap()
|
||||
.args(["rate", "correct horse battery staple table cocoa rocket spirit ferment"])
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("score:"))
|
||||
.stdout(contains("guesses:"))
|
||||
.stdout(contains("strong"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rate_weak_passphrase_exits_zero_with_weak_label() {
|
||||
// `rate` is informational — does NOT exit nonzero on weak input.
|
||||
// The hard gate lives at `init` (Plan 2B Task 10).
|
||||
Command::cargo_bin("relicario").unwrap()
|
||||
.args(["rate", "password"])
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("very weak").or(contains("weak")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rate_reads_from_stdin_when_arg_is_dash() {
|
||||
Command::cargo_bin("relicario").unwrap()
|
||||
.args(["rate", "-"])
|
||||
.write_stdin("correcthorsebatterystaple\n")
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("score:"));
|
||||
}
|
||||
|
||||
fn make_test_qr(uri: &str, dest: &std::path::Path) {
|
||||
use image::{ImageBuffer, Luma};
|
||||
let code = qrcode::QrCode::new(uri).expect("QR encode failed");
|
||||
let img: ImageBuffer<Luma<u8>, Vec<u8>> = code
|
||||
.render::<Luma<u8>>()
|
||||
.module_dimensions(8, 8)
|
||||
.build();
|
||||
img.save(dest).expect("save QR PNG");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_login_totp_qr_decodes_otpauth_uri() {
|
||||
use tempfile::TempDir;
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let qr_path = tmp.path().join("test.png");
|
||||
make_test_qr(
|
||||
"otpauth://totp/Example:alice?secret=JBSWY3DPEHPK3PXP&issuer=Example",
|
||||
&qr_path,
|
||||
);
|
||||
|
||||
let v = common::TestVault::init();
|
||||
|
||||
let out = v.run(&[
|
||||
"add", "login",
|
||||
"--title", "TotpTest",
|
||||
"--password", "hunter2",
|
||||
"--totp-qr", qr_path.to_str().unwrap(),
|
||||
]);
|
||||
assert!(out.status.success(), "add failed:\nstdout: {}\nstderr: {}",
|
||||
String::from_utf8_lossy(&out.stdout),
|
||||
String::from_utf8_lossy(&out.stderr));
|
||||
|
||||
let out = v.run(&["get", "TotpTest", "--show"]);
|
||||
assert!(out.status.success(), "get failed:\nstdout: {}\nstderr: {}",
|
||||
String::from_utf8_lossy(&out.stdout),
|
||||
String::from_utf8_lossy(&out.stderr));
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
// BASE32.encode(BASE32.decode("JBSWY3DPEHPK3PXP")) should round-trip.
|
||||
// The secret bytes from JBSWY3DPEHPK3PXP decode to specific bytes,
|
||||
// then re-encode to JBSWY3DPEHPK3PXP====; we check for the core chars.
|
||||
assert!(
|
||||
stdout.contains("JBSWY3DPEHPK3PXP"),
|
||||
"expected TOTP secret in get output, got:\n{stdout}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_login_totp_qr_errors_on_non_otpauth_qr() {
|
||||
use tempfile::TempDir;
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let qr_path = tmp.path().join("nottotp.png");
|
||||
make_test_qr("https://example.com", &qr_path);
|
||||
|
||||
let v = common::TestVault::init();
|
||||
|
||||
let out = v.run(&[
|
||||
"add", "login",
|
||||
"--title", "BadQR",
|
||||
"--password", "hunter2",
|
||||
"--totp-qr", qr_path.to_str().unwrap(),
|
||||
]);
|
||||
assert!(
|
||||
!out.status.success(),
|
||||
"expected nonzero exit for non-otpauth QR, but command succeeded"
|
||||
);
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(
|
||||
stderr.contains("not a TOTP URI"),
|
||||
"expected 'not a TOTP URI' in stderr, got:\n{stderr}"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user