use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use zeroize::Zeroizing;
use crate::clipboard;
use crate::crypto::{KdfParams, MasterKey};
use crate::db::Database;
use crate::error::{Error, Result};
use crate::model::{legacy_to_fields, Attachment, Category, Field, FieldKind, Item, LegacyItemData};
use crate::search::{self, Filter};
use crate::store;
use crate::vault;
pub fn read_passphrase(passfile: Option<&Path>) -> Result<Zeroizing<String>> {
if let Some(val) = std::env::var_os("ZKV_PASSPHRASE") {
let s = val
.into_string()
.map_err(|_| Error::Other("ZKV_PASSPHRASE is not valid UTF-8".into()))?;
return Ok(Zeroizing::new(s));
}
if let Some(p) = passfile {
let raw = std::fs::read_to_string(p)
.map_err(|e| Error::Other(format!("failed to read passfile {}: {e}", p.display())))?;
return Ok(Zeroizing::new(strip_trailing_newline(raw)));
}
let pass = rpassword::prompt_password("passphrase: ")?;
Ok(Zeroizing::new(pass))
}
fn strip_trailing_newline(mut s: String) -> String {
if s.ends_with("\r\n") {
s.truncate(s.len() - 2);
} else if s.ends_with('\n') {
s.pop();
}
s
}
pub fn read_new_passphrase(new_passfile: Option<&Path>) -> Result<Zeroizing<String>> {
if let Some(val) = std::env::var_os("ZKV_NEW_PASSPHRASE") {
let s = val
.into_string()
.map_err(|_| Error::Other("ZKV_NEW_PASSPHRASE is not valid UTF-8".into()))?;
return Ok(Zeroizing::new(s));
}
if let Some(p) = new_passfile {
let raw = std::fs::read_to_string(p).map_err(|e| {
Error::Other(format!("failed to read new-passfile {}: {e}", p.display()))
})?;
return Ok(Zeroizing::new(strip_trailing_newline(raw)));
}
let a = rpassword::prompt_password("new passphrase: ")?;
let b = rpassword::prompt_password("confirm new passphrase: ")?;
if a != b {
return Err(Error::Other("passphrases do not match".into()));
}
Ok(Zeroizing::new(a))
}
pub fn run_passwd(path: &Path, old_pf: Option<&Path>, new_pf: Option<&Path>) -> Result<()> {
let old = read_passphrase(old_pf)?;
let new = read_new_passphrase(new_pf)?;
vault::change_passphrase(path, old.as_str(), new.as_str())?;
crate::agent::forget(path);
println!("passphrase changed");
Ok(())
}
pub fn default_vault_path() -> Result<PathBuf> {
let home = std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE")) .ok_or_else(|| Error::Other("cannot determine home dir (set $HOME)".into()))?;
Ok(PathBuf::from(home).join(".zkv").join("default.zkv"))
}
pub fn resolve_vault_path(p: Option<PathBuf>) -> Result<PathBuf> {
match p {
Some(x) => Ok(x),
None => default_vault_path(),
}
}
pub struct Unlocked {
pub db: Database,
pub path: PathBuf,
key: MasterKey,
kdf: KdfParams,
salt: [u8; 16],
}
pub fn run_init(path: &Path, passfile: Option<&Path>) -> Result<()> {
if path.exists() {
return Err(Error::Other(format!(
"vault already exists: {} (refusing to overwrite)",
path.display()
)));
}
let pass = read_passphrase(passfile)?;
vault::create(path, pass.as_str())?;
println!("created vault at {}", path.display());
Ok(())
}
impl Unlocked {
pub fn unlock(path: &Path, passfile: Option<&Path>) -> Result<Unlocked> {
if crate::agent::enabled() {
if let Some((key, ..)) = crate::agent::try_get_key(path) {
match vault::unlock_with_key(path, key) {
Ok((db, key, kdf, salt)) => {
return Ok(Unlocked {
db,
path: path.to_path_buf(),
key,
kdf,
salt,
});
}
Err(_) => crate::agent::forget(path),
}
}
}
let pass = read_passphrase(passfile)?;
let (db, key, kdf, salt) = vault::unlock_full(path, pass.as_str())?;
crate::agent::put_key(path, &key, &kdf, salt);
Ok(Unlocked {
db,
path: path.to_path_buf(),
key,
kdf,
salt,
})
}
pub fn save(&self) -> Result<()> {
vault::save_with_key(&self.path, &self.key, &self.kdf, self.salt, &self.db)
}
}
#[derive(Debug, Clone, Default)]
pub struct ListFilter {
pub template_id: Option<String>,
pub tags: Vec<String>,
pub category: Option<String>,
pub query: Option<String>,
pub favorite_only: bool,
}
fn to_search_filter(list: &ListFilter, category_id: Option<i64>) -> Filter {
Filter {
query: list.query.clone(),
category: category_id,
tags: list.tags.clone(),
template_id: list.template_id.clone(),
favorite_only: list.favorite_only,
}
}
fn category_id_by_name(conn: &rusqlite::Connection, name: &str) -> Result<Option<i64>> {
let cats = store::list_categories(conn)?;
Ok(cats.into_iter().find(|c| c.name == name).and_then(|c| c.id))
}
fn tag_id_by_name(conn: &rusqlite::Connection, name: &str) -> Result<Option<i64>> {
let tags = store::list_tags(conn)?;
Ok(tags.into_iter().find(|t| t.name == name).map(|t| t.id))
}
pub fn find_item_id_by_title(conn: &rusqlite::Connection, query: &str) -> Result<i64> {
let items = store::list_items(conn)?;
if let Some(it) = items.iter().find(|i| i.title == query) {
return Ok(it.id.unwrap_or(-1));
}
let prefix_matches: Vec<&Item> = items.iter().filter(|i| i.title.starts_with(query)).collect();
match prefix_matches.len() {
0 => Err(Error::Other(format!("no item matches '{query}'"))),
1 => Ok(prefix_matches[0].id.unwrap_or(-1)),
_ => {
let names: Vec<&str> = prefix_matches.iter().map(|i| i.title.as_str()).collect();
Err(Error::Other(format!(
"multiple items match '{query}': {}",
names.join(", ")
)))
}
}
}
pub fn resolve_id(
conn: &rusqlite::Connection,
id: Option<i64>,
find: Option<&str>,
) -> Result<i64> {
if let Some(n) = id {
return Ok(n);
}
if let Some(q) = find {
return find_item_id_by_title(conn, q);
}
Err(Error::Other("need an <id> or --find <TITLE>".into()))
}
fn write_items<W: Write>(out: &mut W, items: &[Item], json: bool) -> Result<()> {
if json {
let s = serde_json::to_string_pretty(items)?;
writeln!(out, "{s}")?;
return Ok(());
}
if items.is_empty() {
writeln!(out, "(no items)")?;
return Ok(());
}
for it in items {
let id = it.id.unwrap_or(-1);
let tags = if it.tags.is_empty() {
String::from("-")
} else {
it.tags.join(",")
};
writeln!(
out,
"{}\t{}\t{}\t[{}]\t{}",
id,
it.template_id,
it.title,
tags,
it.updated_at
)?;
}
Ok(())
}
pub fn item_field(item: &Item, field: &str) -> Result<String> {
match field {
"title" => return Ok(item.title.clone()),
"type" => return Ok(item.template_id.clone()),
"totp" | "otp" => {
return item
.totp_value()
.map(|s| s.to_string())
.ok_or_else(|| Error::Other(format!("field '{field}' not found")));
}
_ => {}
}
item.field_value(field)
.map(|s| s.to_string())
.ok_or_else(|| Error::Other(format!("field '{field}' not found")))
}
pub fn parse_otpauth(uri: &str) -> Result<String> {
let lower_prefix = "otpauth://";
let rest = uri
.strip_prefix(lower_prefix)
.ok_or_else(|| Error::Other("otpauth: uri must start with 'otpauth://'".into()))?;
let query = match rest.split_once('?') {
Some((_, q)) => q,
None => {
return Err(Error::Other(
"otpauth: uri missing '?' (no query / no secret)".into(),
));
}
};
let query = query.split('#').next().unwrap_or(query);
for pair in query.split('&') {
if let Some(val) = pair.strip_prefix("secret=") {
let decoded = percent_decode_minimal(val);
if decoded.is_empty() {
return Err(Error::Other("otpauth: empty secret".into()));
}
return Ok(decoded);
}
}
Err(Error::Other("otpauth: uri missing 'secret=' parameter".into()))
}
fn percent_decode_minimal(s: &str) -> String {
let bytes = s.as_bytes();
let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
if let (Some(h), Some(l)) = (hex_digit(bytes[i + 1]), hex_digit(bytes[i + 2])) {
out.push(h * 16 + l);
i += 3;
continue;
}
}
out.push(bytes[i]);
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
fn hex_digit(b: u8) -> Option<u8> {
match b {
b'0'..=b'9' => Some(b - b'0'),
b'a'..=b'f' => Some(b - b'a' + 10),
b'A'..=b'F' => Some(b - b'A' + 10),
_ => None,
}
}
const QR_FETCH_MAX_BYTES: u64 = 8 * 1024 * 1024;
const QR_FETCH_TIMEOUT_SECS: u64 = 15;
pub fn otpauth_from_qr_bytes(bytes: &[u8]) -> Result<String> {
let img = image::load_from_memory(bytes)
.map_err(|e| Error::Other(format!("decode image: {e}")))?;
let gray = img.to_luma8();
let mut prepared = rqrr::PreparedImage::prepare(gray);
for grid in prepared.detect_grids() {
if let Ok((_meta, content)) = grid.decode() {
if content.starts_with("otpauth://") {
return Ok(content);
}
}
}
Err(Error::Other(
"no otpauth qr found in image (no qr, or qr payload is not an otpauth uri)".into(),
))
}
pub fn fetch_url_bytes(url: &str) -> Result<Vec<u8>> {
if let Some(rest) = url.strip_prefix("data:") {
return decode_data_url(rest);
}
if !url.starts_with("http://") && !url.starts_with("https://") {
return Err(Error::Other(format!(
"unsupported url scheme (use http/https/data): {url}"
)));
}
let resp = ureq::get(url)
.timeout(std::time::Duration::from_secs(QR_FETCH_TIMEOUT_SECS))
.call()
.map_err(|e| Error::Other(format!("fetch {url}: {e}")))?;
let reader = resp.into_reader();
let mut buf = Vec::new();
reader
.take(QR_FETCH_MAX_BYTES + 1)
.read_to_end(&mut buf)
.map_err(|e| Error::Other(format!("read body {url}: {e}")))?;
if buf.len() as u64 > QR_FETCH_MAX_BYTES {
return Err(Error::Other(format!(
"image too large (>{QR_FETCH_MAX_BYTES} bytes): {url}"
)));
}
Ok(buf)
}
fn decode_data_url(rest: &str) -> Result<Vec<u8>> {
let (meta, payload) = rest
.split_once(',')
.ok_or_else(|| Error::Other("data: url missing ',' payload".into()))?;
if meta.ends_with(";base64") {
let pad = (4 - (payload.len() % 4)) % 4;
let padded = format!("{payload}{:=<pad$}", "");
data_encoding::BASE64
.decode(padded.as_bytes())
.map_err(|e| Error::Other(format!("decode data: base64: {e}")))
} else {
Ok(payload.as_bytes().to_vec())
}
}
pub fn resolve_totp_source(
otpauth: Option<&str>,
qr_path: Option<&Path>,
qr_url: Option<&str>,
) -> Result<Option<String>> {
if let Some(uri) = otpauth {
return Ok(Some(uri.to_string()));
}
if let Some(path) = qr_path {
let bytes = std::fs::read(path)
.map_err(|e| Error::Other(format!("read qr image {}: {e}", path.display())))?;
return Ok(Some(otpauth_from_qr_bytes(&bytes)?));
}
if let Some(url) = qr_url {
let bytes = fetch_url_bytes(url)?;
return Ok(Some(otpauth_from_qr_bytes(&bytes)?));
}
Ok(None)
}
#[derive(Debug, Default, Clone)]
pub struct EditFields {
pub sets: Vec<(String, String)>,
}
impl EditFields {
pub fn any(&self) -> bool {
!self.sets.is_empty()
}
pub fn apply(&self, item: &mut Item) -> bool {
if self.sets.is_empty() {
return false;
}
let mut changed = false;
for (name, value) in &self.sets {
if let Some(f) = item.fields.iter_mut().find(|f| &f.name == name) {
f.value = value.clone();
} else {
let kind = FieldKind::Text;
item.fields.push(Field {
name: name.clone(),
value: value.clone(),
kind,
protected: matches!(kind, FieldKind::Secret | FieldKind::Totp),
});
}
changed = true;
}
changed
}
}
#[derive(Debug, Default, Clone)]
pub struct TagDelta {
pub add: Vec<String>,
pub remove: Vec<String>,
}
impl TagDelta {
pub fn any(&self) -> bool {
!self.add.is_empty() || !self.remove.is_empty()
}
pub fn apply(&self, tags: &[String]) -> Vec<String> {
let mut out: Vec<String> = tags.to_vec();
if !self.remove.is_empty() {
out.retain(|t| !self.remove.iter().any(|r| r == t));
}
for t in &self.add {
if !out.iter().any(|e| e == t) {
out.push(t.clone());
}
}
out
}
}
fn parse_item_data(data_json: &str, default_template: &str) -> Result<(String, Vec<Field>)> {
if let Ok(fields) = serde_json::from_str::<Vec<Field>>(data_json) {
return Ok((default_template.to_string(), fields));
}
#[derive(serde::Deserialize)]
struct ItemShape {
#[serde(default)]
template_id: Option<String>,
#[serde(default)]
fields: Vec<Field>,
}
if let Ok(shape) = serde_json::from_str::<ItemShape>(data_json) {
if !shape.fields.is_empty() || shape.template_id.is_some() {
let tpl = shape
.template_id
.unwrap_or_else(|| default_template.to_string());
return Ok((tpl, shape.fields));
}
}
let legacy: LegacyItemData = serde_json::from_str(data_json)?;
Ok(legacy_to_fields(legacy))
}
#[allow(clippy::too_many_arguments)]
pub fn run_add(
u: &Unlocked,
title: &str,
template: &str,
data_json: Option<&str>,
sets: &EditFields,
tags: Vec<String>,
favorite: bool,
gen_password: Option<usize>,
otpauth: Option<&str>,
) -> Result<i64> {
let (template_id, fields) = match data_json {
Some(j) if !j.trim().is_empty() => parse_item_data(j, template)?,
_ => (
template.to_string(),
crate::model::instantiate_template(template).unwrap_or_default(),
),
};
let mut draft = Item {
id: None,
template_id,
title: title.into(),
category_id: None,
fields,
favorite,
tags,
created_at: 0,
updated_at: 0,
};
sets.apply(&mut draft);
let generated = if let Some(len) = gen_password {
let pw = generate_password(len, true, true)?;
let target = draft
.fields
.iter_mut()
.find(|f| f.name == "password" && f.kind == FieldKind::Secret);
match target {
Some(f) => {
f.value = pw.clone();
}
None => {
return Err(Error::Other(
"--gen-password needs a 'password' Secret field".into(),
));
}
}
Some(pw)
} else {
None
};
if let Some(uri) = otpauth {
let secret = parse_otpauth(uri)?;
let target = draft.fields.iter_mut().find(|f| f.kind == FieldKind::Totp);
match target {
Some(f) => f.value = secret,
None => {
return Err(Error::Other("--otpauth needs a Totp field".into()));
}
}
}
let id = store::insert_item(u.db.conn(), &mut draft)?;
u.save()?;
println!("added item {id}: {title}");
if let Some(pw) = generated {
eprintln!("generated password for item {id}: {pw}");
}
if otpauth.is_some() || sets.sets.iter().any(|(n, _)| n == "totp") {
eprint_totp_hint(&draft);
}
Ok(id)
}
const AMBIGUOUS_CHARS: &[u8] = b"0Oo1lI|5S2ZB8";
const SYMBOL_CHARS: &[u8] = b"!@#$%^&*()-_=+[]{};:,.?/";
pub fn generate_password(length: usize, symbols: bool, avoid_ambiguous: bool) -> Result<String> {
if length < 4 {
return Err(Error::Other("password length too short (min 4)".into()));
}
if length > 1024 {
return Err(Error::Other("password length too long (max 1024)".into()));
}
let mut pool: Vec<u8> = Vec::with_capacity(26 * 2 + 10 + SYMBOL_CHARS.len());
pool.extend(b'a'..=b'z');
pool.extend(b'A'..=b'Z');
pool.extend(b'0'..=b'9');
if symbols {
pool.extend_from_slice(SYMBOL_CHARS);
}
if avoid_ambiguous {
pool.retain(|c| !AMBIGUOUS_CHARS.contains(c));
}
let pool_len = pool.len();
let limit = 256 - (256 % pool_len);
let mut out: Vec<u8> = Vec::with_capacity(length);
let mut buf = vec![0u8; 64];
let mut pos = buf.len(); while out.len() < length {
if pos >= buf.len() {
getrandom::fill(&mut buf)
.map_err(|e| Error::Other(format!("getrandom failed: {e}")))?;
pos = 0;
}
let byte = buf[pos] as usize;
pos += 1;
if byte < limit {
out.push(pool[byte % pool_len]);
}
}
String::from_utf8(out)
.map_err(|e| Error::Other(format!("generated password not utf-8: {e}")))
}
pub fn run_gen(length: usize, symbols: bool, avoid_ambiguous: bool) -> Result<()> {
let pw = generate_password(length, symbols, avoid_ambiguous)?;
let stdout = std::io::stdout();
let mut out = stdout.lock();
writeln!(out, "{pw}")?;
Ok(())
}
#[allow(clippy::too_many_arguments)]
pub fn run_edit(
u: &Unlocked,
id: i64,
title: Option<&str>,
data_json: Option<&str>,
tags: Option<Vec<String>>,
favorite: Option<bool>,
category: Option<&str>,
fields: &EditFields,
tag_delta: &TagDelta,
otpauth: Option<&str>,
) -> Result<()> {
if data_json.is_some() && fields.any() {
return Err(Error::Other(
"--data conflicts with field flags".into(),
));
}
if tags.is_some() && tag_delta.any() {
return Err(Error::Other(
"--tag conflicts with --add-tag/--rm-tag".into(),
));
}
let conn = u.db.conn();
let mut item = store::get_item(conn, id)?
.ok_or_else(|| Error::Other(format!("item {id} not found")))?;
let mut changed = false;
if let Some(t) = title {
item.title = t.into();
changed = true;
}
if let Some(j) = data_json {
let (tpl, fields) = parse_item_data(j, &item.template_id)?;
item.template_id = tpl;
item.fields = fields;
changed = true;
}
if let Some(tg) = tags {
item.tags = tg;
changed = true;
}
if tag_delta.any() {
item.tags = tag_delta.apply(&item.tags);
changed = true;
}
if let Some(f) = favorite {
item.favorite = f;
changed = true;
}
if let Some(cat) = category {
let cid = category_id_by_name(conn, cat)?
.ok_or_else(|| Error::Other(format!("category '{cat}' not found")))?;
item.category_id = Some(cid);
changed = true;
}
if fields.any() && fields.apply(&mut item) {
changed = true;
}
if let Some(uri) = otpauth {
let secret = parse_otpauth(uri)?;
let target = item.fields.iter_mut().find(|f| f.kind == FieldKind::Totp);
match target {
Some(f) => {
f.value = secret;
changed = true;
}
None => {
return Err(Error::Other("--otpauth needs a Totp field".into()));
}
}
}
if !changed {
return Err(Error::Other("edit: nothing to change".into()));
}
store::update_item(conn, &item)?;
u.save()?;
println!("updated item {id}");
if otpauth.is_some() || fields.sets.iter().any(|(n, _)| n == "totp") {
eprint_totp_hint(&item);
}
Ok(())
}
pub fn run_rm(u: &Unlocked, id: i64, yes: bool) -> Result<()> {
use std::io::BufRead;
let conn = u.db.conn();
let item = store::get_item(conn, id)?
.ok_or_else(|| Error::Other(format!("item {id} not found")))?;
if !yes {
let mut stderr = std::io::stderr();
write!(stderr, "delete \"{}\"? [y/N] ", item.title)?;
stderr.flush()?;
let stdin = std::io::stdin();
let lock = stdin.lock();
let confirm = lock
.lines()
.next()
.unwrap_or(Ok(String::new()))
.unwrap_or_default();
let first = confirm.trim_start().chars().next();
if !matches!(first, Some('y' | 'Y')) {
println!("aborted");
return Ok(());
}
}
store::delete_item(conn, id)?;
u.save()?;
println!("deleted item {id}");
Ok(())
}
pub fn run_cat_add(u: &Unlocked, name: &str, parent: Option<&str>) -> Result<i64> {
let conn = u.db.conn();
let parent_id = match parent {
Some(pn) => Some(
category_id_by_name(conn, pn)?
.ok_or_else(|| Error::Other(format!("parent category '{pn}' not found")))?,
),
None => None,
};
let mut cat = Category {
id: None,
name: name.into(),
parent_id,
sort_order: 0,
};
let id = store::insert_category(conn, &mut cat)?;
u.save()?;
println!("added category {id}: {name}");
Ok(id)
}
pub fn run_cat_rm(u: &Unlocked, target: &str) -> Result<()> {
let conn = u.db.conn();
let id = resolve_category(conn, target)?;
store::delete_category(conn, id)?;
u.save()?;
println!("deleted category {id}");
Ok(())
}
pub fn run_cat_ls(u: &Unlocked) -> Result<()> {
let conn = u.db.conn();
let cats = store::list_categories(conn)?;
let stdout = std::io::stdout();
let mut out = stdout.lock();
if cats.is_empty() {
writeln!(out, "(no categories)")?;
return Ok(());
}
let id_to_name: std::collections::HashMap<i64, String> = cats
.iter()
.filter_map(|c| c.id.map(|i| (i, c.name.clone())))
.collect();
for c in &cats {
let id = c.id.unwrap_or(-1);
let parent = c
.parent_id
.and_then(|pid| id_to_name.get(&pid).cloned())
.unwrap_or_else(|| "-".into());
writeln!(out, "{id}\t{}\t{parent}\t{}", c.name, c.sort_order)?;
}
Ok(())
}
fn resolve_category(conn: &rusqlite::Connection, target: &str) -> Result<i64> {
if let Ok(n) = target.parse::<i64>() {
return Ok(n);
}
category_id_by_name(conn, target)?
.ok_or_else(|| Error::Other(format!("category '{target}' not found")))
}
pub fn run_tag_ls(u: &Unlocked) -> Result<()> {
let conn = u.db.conn();
let tags = store::list_tags(conn)?;
let stdout = std::io::stdout();
let mut out = stdout.lock();
if tags.is_empty() {
writeln!(out, "(no tags)")?;
return Ok(());
}
for t in tags {
writeln!(out, "{}\t{}", t.id, t.name)?;
}
Ok(())
}
pub fn run_tag_rm(u: &Unlocked, name: &str) -> Result<()> {
let conn = u.db.conn();
let id = tag_id_by_name(conn, name)?
.ok_or_else(|| Error::Other(format!("tag '{name}' not found")))?;
store::delete_tag(conn, id)?;
u.save()?;
println!("deleted tag {name}");
Ok(())
}
pub fn run_tag_mv(u: &Unlocked, from: &str, to: &str) -> Result<()> {
let conn = u.db.conn();
let id = tag_id_by_name(conn, from)?
.ok_or_else(|| Error::Other(format!("tag '{from}' not found")))?;
store::update_tag(conn, id, to)?;
u.save()?;
println!("renamed tag {from} -> {to}");
Ok(())
}
pub fn guess_mime(path: &Path) -> Option<String> {
let ext = path.extension()?.to_str()?.to_ascii_lowercase();
let mime = match ext.as_str() {
"pdf" => "application/pdf",
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
"gif" => "image/gif",
"svg" => "image/svg+xml",
"webp" => "image/webp",
"txt" | "log" | "md" => "text/plain",
"csv" => "text/csv",
"html" | "htm" => "text/html",
"json" => "application/json",
"xml" => "application/xml",
"zip" => "application/zip",
"gz" | "tgz" => "application/gzip",
"tar" => "application/x-tar",
"docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"pptx" => "application/vnd.openxmlformats-officedocument.presentationml.presentation",
"doc" => "application/msword",
"xls" => "application/vnd.ms-excel",
"ppt" => "application/vnd.ms-powerpoint",
"bin" | "dat" => "application/octet-stream",
_ => return None,
};
Some(mime.to_string())
}
fn attachment_belongs_to(conn: &rusqlite::Connection, att: i64, item: i64) -> Result<bool> {
let row: Option<i64> = conn
.query_row(
"SELECT item_id FROM attachments WHERE id = ?1",
rusqlite::params![att],
|r| r.get::<_, i64>(0),
)
.ok();
Ok(row == Some(item))
}
pub fn run_attach_add(
u: &Unlocked,
item: i64,
file: &Path,
mime: Option<&str>,
) -> Result<i64> {
let conn = u.db.conn();
if store::get_item(conn, item)?.is_none() {
return Err(Error::Other(format!("item {item} not found")));
}
let blob = std::fs::read(file)?;
let filename = file
.file_name()
.and_then(|n| n.to_str())
.map(|s| s.to_string())
.unwrap_or_else(|| "attachment".to_string());
let mime_type = mime.map(|s| s.to_string()).or_else(|| guess_mime(file));
let mut att = Attachment {
id: None,
item_id: item,
filename,
mime_type,
size: 0, blob,
};
let id = store::insert_attachment(conn, &mut att)?;
u.save()?;
println!(
"attached {}: {} ({} bytes)",
id,
att.filename,
att.size
);
Ok(id)
}
pub fn run_attach_ls(u: &Unlocked, item: i64) -> Result<()> {
let conn = u.db.conn();
let mut stmt = conn.prepare(
"SELECT id, filename, mime_type, size FROM attachments
WHERE item_id = ?1 ORDER BY id ASC",
)?;
let rows: Vec<(i64, String, Option<String>, i64)> = stmt
.query_map(rusqlite::params![item], |r| {
Ok((
r.get::<_, i64>(0)?,
r.get::<_, String>(1)?,
r.get::<_, Option<String>>(2)?,
r.get::<_, i64>(3)?,
))
})?
.filter_map(|r| r.ok())
.collect();
let stdout = std::io::stdout();
let mut out = stdout.lock();
if rows.is_empty() {
writeln!(out, "(no attachments)")?;
return Ok(());
}
for (id, filename, mime, size) in rows {
let mime = mime.unwrap_or_else(|| "-".into());
writeln!(out, "{id}\t{filename}\t{mime}\t{size}")?;
}
Ok(())
}
pub fn run_attach_get(
u: &Unlocked,
item: i64,
att: i64,
output: Option<&Path>,
) -> Result<()> {
let conn = u.db.conn();
let attachment = store::get_attachment(conn, att)?
.ok_or_else(|| Error::Other(format!("attachment {att} not found")))?;
if !attachment_belongs_to(conn, att, item)? {
return Err(Error::Other(format!(
"attachment {att} does not belong to item {item}"
)));
}
let mime = attachment.mime_type.clone().unwrap_or_else(|| "-".into());
eprintln!(
"{}\t{}\t{} bytes",
attachment.filename, mime, attachment.size
);
match output {
Some(p) => {
std::fs::write(p, &attachment.blob)?;
eprintln!("wrote {}", p.display());
}
None => {
let stdout = std::io::stdout();
let mut out = stdout.lock();
out.write_all(&attachment.blob)?;
}
}
Ok(())
}
pub fn run_attach_rm(u: &Unlocked, item: i64, att: i64) -> Result<()> {
let conn = u.db.conn();
if store::get_attachment(conn, att)?.is_none() {
return Err(Error::Other(format!("attachment {att} not found")));
}
if !attachment_belongs_to(conn, att, item)? {
return Err(Error::Other(format!(
"attachment {att} does not belong to item {item}"
)));
}
store::delete_attachment(conn, att)?;
u.save()?;
println!("deleted attachment {att}");
Ok(())
}
pub struct ImportResult {
pub ok: usize,
pub fail: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
#[value(rename_all = "lowercase")]
pub enum Format {
Json,
Csv,
}
#[derive(serde::Serialize, serde::Deserialize)]
struct BackupEnvelope {
items: Vec<Item>,
#[serde(default)]
attachments: Vec<Attachment>,
}
pub fn build_backup_json(items: &[Item], attachments: &[Attachment]) -> Result<String> {
Ok(serde_json::to_string(&BackupEnvelope {
items: items.to_vec(),
attachments: attachments.to_vec(),
})?)
}
pub fn export_json(items: &[Item]) -> Result<String> {
Ok(serde_json::to_string(items)?)
}
pub fn import_json(s: &str) -> Result<(Vec<Item>, Option<Vec<Attachment>>)> {
if let Ok(env) = serde_json::from_str::<BackupEnvelope>(s) {
return Ok((env.items, Some(env.attachments)));
}
match serde_json::from_str::<Vec<Item>>(s) {
Ok(v) => Ok((v, None)),
Err(new_err) => {
#[derive(serde::Deserialize)]
struct LegacyItem {
id: Option<i64>,
#[serde(alias = "item_type", alias = "type")]
template_id: Option<String>,
title: String,
category_id: Option<i64>,
#[serde(default)]
favorite: bool,
tags: Vec<String>,
created_at: i64,
updated_at: i64,
#[serde(flatten)]
extra: serde_json::Value,
}
if let Ok(arr) = serde_json::from_str::<Vec<LegacyItem>>(s) {
let mut out = Vec::new();
for li in arr {
let (tpl, fields) = if let Some(data) = li.extra.get("data") {
if let Ok(legacy) =
serde_json::from_value::<LegacyItemData>(data.clone())
{
legacy_to_fields(legacy)
} else if let Ok(fs) = serde_json::from_value::<Vec<Field>>(data.clone()) {
(li.template_id.clone().unwrap_or_else(|| "password".into()), fs)
} else {
(li.template_id.clone().unwrap_or_else(|| "password".into()), Vec::new())
}
} else {
(li.template_id.clone().unwrap_or_else(|| "password".into()), Vec::new())
};
out.push(Item {
id: li.id,
template_id: tpl,
title: li.title,
category_id: li.category_id,
fields,
favorite: li.favorite,
tags: li.tags,
created_at: li.created_at,
updated_at: li.updated_at,
});
}
Ok((out, None))
} else {
Err(new_err.into())
}
}
}
}
pub fn export_csv(items: &[Item]) -> (String, usize) {
let mut out = String::from("title,username,password,url,totp_secret,notes,tags\n");
let mut skipped = 0usize;
for it in items {
if it.template_id != "password" {
skipped += 1;
continue;
}
let get = |name: &str| -> String {
it.field_value(name).unwrap_or("").to_string()
};
let username = get("username");
let password = get("password");
let url = get("url");
let totp_secret = get("totp");
let notes = get("notes");
out.push_str(&csv_join(&[
&it.title,
&username,
&password,
&url,
&totp_secret,
¬es,
&it.tags.join(";"),
]));
out.push('\n');
}
(out, skipped)
}
pub fn import_csv(s: &str) -> Result<(Vec<Item>, usize)> {
let rows = csv_split(s);
if rows.is_empty() {
return Ok((Vec::new(), 0));
}
let header = &rows[0];
let idx = |name: &str| -> Option<usize> {
header.iter().position(|h| h == name)
};
let Some(i_title) = idx("title") else {
return Err(Error::Other(
"csv header missing 'title' column".into(),
));
};
let get = |row: &[String], i: Option<usize>| -> String {
i.and_then(|n| row.get(n).cloned()).unwrap_or_default()
};
let mut items = Vec::new();
let mut fail = 0usize;
for row in &rows[1..] {
if row.is_empty() || row.iter().all(|f| f.is_empty()) {
fail += 1;
continue;
}
if row.len() <= i_title {
fail += 1;
continue;
}
let title = row[i_title].clone();
let username = get(row, idx("username"));
let password = get(row, idx("password"));
let url = get(row, idx("url"));
let totp_secret = get(row, idx("totp_secret"));
let notes = get(row, idx("notes"));
let tags_raw = get(row, idx("tags"));
let tags: Vec<String> = tags_raw
.split(';')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
let item = Item {
id: None,
template_id: "password".into(),
title,
category_id: None,
fields: vec![
Field { name: "username".into(), value: username, kind: FieldKind::Text, protected: false },
Field { name: "password".into(), value: password, kind: FieldKind::Secret, protected: true },
Field { name: "url".into(), value: url, kind: FieldKind::Text, protected: false },
Field { name: "totp".into(), value: totp_secret, kind: FieldKind::Totp, protected: true },
Field { name: "notes".into(), value: notes, kind: FieldKind::Multiline, protected: false },
],
favorite: false,
tags,
created_at: 0,
updated_at: 0,
};
items.push(item);
}
Ok((items, fail))
}
fn csv_field(s: &str) -> String {
if s.contains([',', '"', '\n', '\r']) {
let escaped = s.replace('"', "\"\"");
format!("\"{escaped}\"")
} else {
s.to_string()
}
}
fn csv_join(fields: &[&str]) -> String {
fields
.iter()
.map(|f| csv_field(f))
.collect::<Vec<_>>()
.join(",")
}
fn csv_split(s: &str) -> Vec<Vec<String>> {
let mut rows: Vec<Vec<String>> = Vec::new();
let mut row: Vec<String> = Vec::new();
let mut field = String::new();
let mut in_quotes = false;
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if in_quotes {
if c == '"' {
if chars.peek() == Some(&'"') {
chars.next();
field.push('"');
} else {
in_quotes = false;
}
} else {
field.push(c);
}
} else if c == '"' {
in_quotes = true;
} else if c == ',' {
row.push(std::mem::take(&mut field));
} else if c == '\n' {
if field.ends_with('\r') {
field.pop();
}
row.push(std::mem::take(&mut field));
rows.push(std::mem::take(&mut row));
} else {
field.push(c);
}
}
if !field.is_empty() || !row.is_empty() {
if field.ends_with('\r') {
field.pop();
}
row.push(field);
rows.push(row);
}
rows
}
pub fn run_export(
u: &Unlocked,
format: Format,
output: Option<&Path>,
) -> Result<()> {
let conn = u.db.conn();
let items = store::list_items(conn)?;
let content = match format {
Format::Json => {
let attachments = store::list_all_attachments(conn)?;
build_backup_json(&items, &attachments)?
}
Format::Csv => {
let (csv, skipped) = export_csv(&items);
if skipped > 0 {
eprintln!("skipped {skipped} non-password items");
}
csv
}
};
match output {
Some(p) => {
write_secret_file(p, content.as_bytes())?;
eprintln!("exported {} items to {}", items.len(), p.display());
}
None => {
let stdout = std::io::stdout();
let mut out = stdout.lock();
out.write_all(content.as_bytes())?;
}
}
Ok(())
}
pub fn run_import(u: &Unlocked, format: Format, input: Option<&Path>) -> Result<ImportResult> {
let raw = match input {
Some(p) => std::fs::read_to_string(p)?,
None => {
use std::io::Read;
let mut s = String::new();
std::io::stdin().read_to_string(&mut s)?;
s
}
};
let (candidates, attachments_opt, parse_fail): (Vec<Item>, Option<Vec<Attachment>>, usize) =
match format {
Format::Json => match import_json(&raw) {
Ok((items, atts)) => (items, atts, 0),
Err(_) => (Vec::new(), None, 1), },
Format::Csv => {
let (items, fail) = import_csv(&raw)?;
(items, None, fail)
}
};
let conn = u.db.conn();
let mut ok = 0usize;
let mut fail = parse_fail;
let mut old_to_new: std::collections::HashMap<i64, i64> = std::collections::HashMap::new();
for mut item in candidates {
let old_id = item.id;
item.id = None; match store::insert_item(conn, &mut item) {
Ok(new_id) => {
if let Some(oid) = old_id {
old_to_new.insert(oid, new_id);
}
ok += 1;
}
Err(_) => fail += 1,
}
}
let mut att_ok = 0usize;
let mut att_fail = 0usize;
if let Some(atts) = attachments_opt {
for mut att in atts {
let new_item_id = match old_to_new.get(&att.item_id).copied() {
Some(nid) => nid,
None => {
att_fail += 1;
continue;
}
};
att.id = None;
att.item_id = new_item_id;
match store::insert_attachment(conn, &mut att) {
Ok(_) => att_ok += 1,
Err(_) => att_fail += 1,
}
}
}
let changed = ok > 0 || att_ok > 0;
if changed {
u.save()?;
}
if att_fail == 0 {
println!("imported {ok} items ({att_ok} attachments)");
} else {
println!("imported {ok} items ({att_ok} attachments, {att_fail} failed)");
}
fail += att_fail;
Ok(ImportResult { ok, fail })
}
fn write_secret_file(path: &Path, data: &[u8]) -> Result<()> {
std::fs::write(path, data)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::metadata(path)?.permissions();
let mut perms = perms;
perms.set_mode(0o600);
std::fs::set_permissions(path, perms)?;
}
Ok(())
}
pub fn run_ls(u: &Unlocked, f: &ListFilter, favorite: bool, json: bool) -> Result<()> {
let mut f = f.clone();
if favorite {
f.favorite_only = true;
}
let conn = u.db.conn();
let category_id = match &f.category {
Some(name) => Some(
category_id_by_name(conn, name)?
.ok_or_else(|| Error::Other(format!("category '{name}' not found")))?,
),
None => None,
};
let sf = to_search_filter(&f, category_id);
let items = search::search(conn, &sf)?;
let stdout = std::io::stdout();
let mut out = stdout.lock();
write_items(&mut out, &items, json)
}
pub fn run_get(u: &Unlocked, id: i64, field: Option<&str>, json: bool) -> Result<()> {
let conn = u.db.conn();
let item = store::get_item(conn, id)?
.ok_or_else(|| Error::Other(format!("item {id} not found")))?;
let stdout = std::io::stdout();
let mut out = stdout.lock();
match field {
Some(name) => {
let val = item_field(&item, name)?;
writeln!(out, "{val}")?;
}
None => {
if json {
let s = serde_json::to_string_pretty(&item)?;
writeln!(out, "{s}")?;
} else {
write_item_human(&mut out, &item)?;
}
}
}
Ok(())
}
pub fn run_search(u: &Unlocked, query: &str, json: bool) -> Result<()> {
let sf = Filter {
query: Some(query.to_string()),
category: None,
tags: vec![],
template_id: None,
favorite_only: false,
};
let items = search::search(u.db.conn(), &sf)?;
let stdout = std::io::stdout();
let mut out = stdout.lock();
write_items(&mut out, &items, json)
}
pub fn run_cp(u: &Unlocked, id: i64, field: Option<&str>, clear_secs: u64) -> Result<()> {
let conn = u.db.conn();
let item = store::get_item(conn, id)?
.ok_or_else(|| Error::Other(format!("item {id} not found")))?;
let name = field.unwrap_or("password");
if name == "otp" {
let code = otp_of_item(&item)?;
clipboard::copy_and_clear_after(&code, clear_secs)?;
println!("copied otp code (clears in {clear_secs}s)");
return Ok(());
}
let val = item_field(&item, name)?;
clipboard::copy_and_clear_after(&val, clear_secs)?;
println!("copied {name} (clears in {clear_secs}s)");
Ok(())
}
fn eprint_totp_hint(item: &Item) {
let Some(secret) = item.totp_value().filter(|s| !s.trim().is_empty()) else {
return;
};
let Ok(code) = crate::totp::current_totp(secret) else {
return;
};
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
eprintln!("totp: {code} (valid ~{}s)", 30 - secs % 30);
}
pub fn otp_of_item(item: &Item) -> Result<String> {
let secret = item
.totp_value()
.ok_or_else(|| Error::Other("item has no totp secret".into()))?;
if secret.trim().is_empty() {
return Err(Error::Other("item has no totp secret".into()));
}
crate::totp::current_totp(secret)
}
pub fn run_otp(u: &Unlocked, id: i64) -> Result<()> {
let conn = u.db.conn();
let item = store::get_item(conn, id)?
.ok_or_else(|| Error::Other(format!("item {id} not found")))?;
let code = otp_of_item(&item)?;
let stdout = std::io::stdout();
let mut out = stdout.lock();
writeln!(out, "{code}")?;
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
eprintln!("(valid ~{}s)", 30 - secs % 30);
Ok(())
}
fn write_item_human<W: Write>(out: &mut W, item: &Item) -> Result<()> {
let id = item.id.unwrap_or(-1);
writeln!(out, "id: {id}")?;
writeln!(out, "type: {}", item.template_id)?;
writeln!(out, "title: {}", item.title)?;
if item.favorite {
writeln!(out, "favorite: yes")?;
}
if !item.tags.is_empty() {
writeln!(out, "tags: {}", item.tags.join(", "))?;
}
for f in &item.fields {
writeln!(out, "{:<9}{}", f.name, f.value)?;
}
writeln!(out, "updated: {}", item.updated_at)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{Field, FieldKind};
use crate::store;
use crate::test_support::{mk_item, mk_password_item};
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
use std::sync::Mutex;
static LOCK: Mutex<()> = Mutex::new(());
LOCK.lock().unwrap()
}
fn fast_kdf() -> KdfParams {
KdfParams {
m_kib: 4_096,
t_cost: 1,
p_cost: 1,
}
}
fn tmp_path(tag: &str) -> PathBuf {
use std::sync::atomic::{AtomicU64, Ordering};
static C: AtomicU64 = AtomicU64::new(0);
let n = C.fetch_add(1, Ordering::Relaxed);
let mut p = std::env::temp_dir();
p.push(format!("zkv_cli_{tag}_{}_{}", std::process::id(), n));
p
}
fn cleanup(p: &Path) {
let _ = std::fs::remove_file(p);
let mut t = p.as_os_str().to_owned();
t.push(".tmp");
let _ = std::fs::remove_file(PathBuf::from(t));
}
fn write_passfile(tag: &str) -> PathBuf {
let p = tmp_path(&format!("pf_{tag}"));
std::fs::write(&p, "pw\n").unwrap();
p
} fn make_vault(tag: &str) -> PathBuf {
let p = tmp_path(tag);
cleanup(&p);
let kdf = fast_kdf();
vault::create_with_params(&p, "pw", &kdf).unwrap();
let (db, key, kdf2, salt) = vault::unlock_full(&p, "pw").unwrap();
{
let conn = db.conn();
let mut pw = Item {
id: None,
template_id: "password".into(),
title: "GitHub".into(),
category_id: None,
fields: vec![
Field { name: "username".into(), value: "alice".into(), kind: FieldKind::Text, protected: false },
Field { name: "password".into(), value: "s3cret".into(), kind: FieldKind::Secret, protected: true },
Field { name: "url".into(), value: "https://github.com".into(), kind: FieldKind::Text, protected: false },
Field { name: "totp".into(), value: "JBSWY3DPEHPK3PXP".into(), kind: FieldKind::Totp, protected: true },
Field { name: "notes".into(), value: "main".into(), kind: FieldKind::Multiline, protected: false },
],
favorite: false,
tags: vec!["work".into(), "vip".into()],
created_at: 0,
updated_at: 0,
};
store::insert_item(conn, &mut pw).unwrap();
let mut note = Item {
id: None,
template_id: "note".into(),
title: "Ideas".into(),
category_id: None,
fields: vec![
Field { name: "format".into(), value: "markdown".into(), kind: FieldKind::Text, protected: false },
Field { name: "content".into(), value: "# hello world".into(), kind: FieldKind::Multiline, protected: false },
],
favorite: false,
tags: vec!["work".into()],
created_at: 0,
updated_at: 0,
};
store::insert_item(conn, &mut note).unwrap();
}
vault::save_with_key(&p, &key, &kdf2, salt, &db).unwrap();
drop(db);
p
}
#[test]
fn strip_trailing_newline_both_styles() {
assert_eq!(strip_trailing_newline("abc\n".into()), "abc");
assert_eq!(strip_trailing_newline("abc\r\n".into()), "abc");
assert_eq!(strip_trailing_newline("abc".into()), "abc");
assert_eq!(strip_trailing_newline("a\nb\n".into()), "a\nb");
}
#[test]
fn item_field_password_mapping() {
let item = Item {
id: Some(1),
template_id: "password".into(),
title: "T".into(),
category_id: None,
fields: vec![
Field { name: "username".into(), value: "u".into(), kind: FieldKind::Text, protected: false },
Field { name: "password".into(), value: "p".into(), kind: FieldKind::Secret, protected: true },
Field { name: "url".into(), value: "https://x".into(), kind: FieldKind::Text, protected: false },
Field { name: "totp".into(), value: "TOTP".into(), kind: FieldKind::Totp, protected: true },
Field { name: "notes".into(), value: "n".into(), kind: FieldKind::Multiline, protected: false },
],
favorite: false,
tags: vec![],
created_at: 0,
updated_at: 0,
};
assert_eq!(item_field(&item, "title").unwrap(), "T");
assert_eq!(item_field(&item, "username").unwrap(), "u");
assert_eq!(item_field(&item, "password").unwrap(), "p");
assert_eq!(item_field(&item, "url").unwrap(), "https://x");
assert_eq!(item_field(&item, "totp").unwrap(), "TOTP");
assert_eq!(item_field(&item, "notes").unwrap(), "n");
}
#[test]
fn item_field_note_and_card_mapping() {
let note = Item {
id: Some(1),
template_id: "note".into(),
title: "N".into(),
category_id: None,
fields: vec![
Field { name: "format".into(), value: "markdown".into(), kind: FieldKind::Text, protected: false },
Field { name: "content".into(), value: "body".into(), kind: FieldKind::Multiline, protected: false },
],
favorite: false,
tags: vec![],
created_at: 0,
updated_at: 0,
};
assert_eq!(item_field(¬e, "format").unwrap(), "markdown");
assert_eq!(item_field(¬e, "content").unwrap(), "body");
let card = Item {
id: Some(2),
template_id: "card".into(),
title: "C".into(),
category_id: None,
fields: vec![
Field { name: "holder".into(), value: "H".into(), kind: FieldKind::Text, protected: false },
Field { name: "number".into(), value: "4111".into(), kind: FieldKind::Secret, protected: true },
Field { name: "expiry".into(), value: "12/29".into(), kind: FieldKind::Text, protected: false },
Field { name: "cvv".into(), value: "123".into(), kind: FieldKind::Secret, protected: true },
Field { name: "bank".into(), value: "B".into(), kind: FieldKind::Text, protected: false },
Field { name: "notes".into(), value: "cn".into(), kind: FieldKind::Multiline, protected: false },
],
favorite: false,
tags: vec![],
created_at: 0,
updated_at: 0,
};
assert_eq!(item_field(&card, "holder").unwrap(), "H");
assert_eq!(item_field(&card, "number").unwrap(), "4111");
assert_eq!(item_field(&card, "expiry").unwrap(), "12/29");
assert_eq!(item_field(&card, "cvv").unwrap(), "123");
assert_eq!(item_field(&card, "bank").unwrap(), "B");
assert_eq!(item_field(&card, "notes").unwrap(), "cn");
}
#[test]
fn item_field_unknown_field_errors() {
let item = Item {
id: Some(1),
template_id: "note".into(),
title: "N".into(),
category_id: None,
fields: vec![
Field { name: "format".into(), value: "text".into(), kind: FieldKind::Text, protected: false },
Field { name: "content".into(), value: "c".into(), kind: FieldKind::Multiline, protected: false },
],
favorite: false,
tags: vec![],
created_at: 0,
updated_at: 0,
};
assert!(item_field(&item, "password").is_err());
assert!(item_field(&item, "nope").is_err());
}
#[test]
fn to_search_filter_maps_fields() {
let lf = ListFilter {
template_id: Some("password".into()),
tags: vec!["a".into()],
category: Some("Personal".into()),
query: Some("q".into()),
favorite_only: true,
};
let sf = to_search_filter(&lf, Some(7));
assert_eq!(sf.template_id.as_deref(), Some("password"));
assert_eq!(sf.tags, vec!["a".to_string()]);
assert_eq!(sf.category, Some(7));
assert_eq!(sf.query.as_deref(), Some("q"));
assert!(sf.favorite_only);
}
#[test]
fn run_ls_returns_ok() {
let p = make_vault("ls");
let pf = write_passfile("u");
let u = Unlocked::unlock(&p, Some(&pf)).unwrap();
let f = ListFilter::default();
assert!(run_ls(&u, &f, false, false).is_ok());
assert!(run_ls(&u, &f, false, true).is_ok());
cleanup(&p);
}
#[test]
fn run_ls_filter_by_type_and_tag() {
let p = make_vault("lsfilt");
let pf = write_passfile("u");
let u = Unlocked::unlock(&p, Some(&pf)).unwrap();
let f = ListFilter {
template_id: Some("note".into()),
..Default::default()
};
let sf = to_search_filter(&f, None);
let items = search::search(u.db.conn(), &sf).unwrap();
assert!(items.iter().all(|i| i.template_id == "note"));
let f2 = ListFilter {
tags: vec!["vip".into()],
..Default::default()
};
let sf2 = to_search_filter(&f2, None);
let items2 = search::search(u.db.conn(), &sf2).unwrap();
assert!(items2.iter().all(|i| i.tags.contains(&"vip".to_string())));
cleanup(&p);
}
#[test]
fn run_ls_unknown_category_errors() {
let p = make_vault("lscat");
let pf = write_passfile("u");
let u = Unlocked::unlock(&p, Some(&pf)).unwrap();
let f = ListFilter {
category: Some("Nonexistent".into()),
..Default::default()
};
let err = run_ls(&u, &f, false, false);
assert!(matches!(err, Err(Error::Other(_))));
cleanup(&p);
}
#[test]
fn run_get_found_and_missing() {
let p = make_vault("get");
let pf = write_passfile("u");
let u = Unlocked::unlock(&p, Some(&pf)).unwrap();
assert!(run_get(&u, 1, Some("password"), false).is_ok());
assert!(run_get(&u, 1, None, false).is_ok());
assert!(run_get(&u, 1, None, true).is_ok());
assert!(matches!(
run_get(&u, 9999, None, false),
Err(Error::Other(_))
));
assert!(matches!(
run_get(&u, 1, Some("nope"), false),
Err(Error::Other(_))
));
cleanup(&p);
}
#[test]
fn run_search_ok() {
let p = make_vault("search");
let pf = write_passfile("u");
let u = Unlocked::unlock(&p, Some(&pf)).unwrap();
assert!(run_search(&u, "hello", false).is_ok());
assert!(run_search(&u, "hello", true).is_ok());
cleanup(&p);
}
#[test]
fn run_cp_missing_item_errors() {
let p = make_vault("cp");
let pf = write_passfile("u");
let u = Unlocked::unlock(&p, Some(&pf)).unwrap();
assert!(matches!(
run_cp(&u, 9999, None, 1),
Err(Error::Other(_))
));
cleanup(&p);
}
#[test]
fn otp_of_item_returns_six_digits() {
let p = make_vault("otp");
let pf = write_passfile("u");
let u = Unlocked::unlock(&p, Some(&pf)).unwrap();
let conn = u.db.conn();
let item = store::get_item(conn, 1).unwrap().unwrap();
let code = otp_of_item(&item).unwrap();
assert_eq!(code.len(), 6);
assert!(code.chars().all(|c| c.is_ascii_digit()));
cleanup(&p);
}
#[test]
fn otp_of_item_no_secret_errors() {
let note = Item {
id: Some(2),
template_id: "note".into(),
title: "N".into(),
category_id: None,
fields: vec![
Field { name: "format".into(), value: "text".into(), kind: FieldKind::Text, protected: false },
Field { name: "content".into(), value: "c".into(), kind: FieldKind::Multiline, protected: false },
],
favorite: false,
tags: vec![],
created_at: 0,
updated_at: 0,
};
assert!(matches!(otp_of_item(¬e), Err(Error::Other(_))));
let pw = Item {
id: Some(3),
template_id: "password".into(),
title: "P".into(),
category_id: None,
fields: vec![
Field { name: "username".into(), value: "u".into(), kind: FieldKind::Text, protected: false },
Field { name: "password".into(), value: "p".into(), kind: FieldKind::Secret, protected: true },
Field { name: "url".into(), value: "".into(), kind: FieldKind::Text, protected: false },
Field { name: "totp".into(), value: " ".into(), kind: FieldKind::Totp, protected: true },
Field { name: "notes".into(), value: "".into(), kind: FieldKind::Multiline, protected: false },
],
favorite: false,
tags: vec![],
created_at: 0,
updated_at: 0,
};
assert!(matches!(otp_of_item(&pw), Err(Error::Other(_))));
}
#[test]
fn run_otp_ok_and_missing_errors() {
let p = make_vault("runotp");
let pf = write_passfile("u");
let u = Unlocked::unlock(&p, Some(&pf)).unwrap();
assert!(run_otp(&u, 1).is_ok());
assert!(matches!(run_otp(&u, 9999), Err(Error::Other(_))));
cleanup(&p);
}
#[test]
fn run_init_creates_unlockable_empty_vault() {
let _g = env_lock();
let p = tmp_path("init_ok");
cleanup(&p);
unsafe {
std::env::set_var("ZKV_PASSPHRASE", "pw");
}
let res = run_init(&p, None);
unsafe {
std::env::remove_var("ZKV_PASSPHRASE");
}
res.unwrap();
assert!(p.exists(), "vault file should exist after init");
let db = vault::unlock(&p, "pw").unwrap();
let items = search::search(db.conn(), &Filter::default()).unwrap();
assert!(items.is_empty());
cleanup(&p);
}
#[test]
fn run_init_refuses_existing_vault() {
let p = tmp_path("init_exists");
cleanup(&p);
vault::create_with_params(&p, "pw", &fast_kdf()).unwrap();
assert!(p.exists());
let pf = write_passfile("init_exists");
let err = run_init(&p, Some(&pf));
assert!(matches!(err, Err(Error::Other(_))));
let msg = match err {
Err(Error::Other(m)) => m,
_ => String::new(),
};
assert!(
msg.contains("already exists"),
"expected 'already exists' in error, got: {msg}"
);
cleanup(&p);
}
#[test]
fn read_passphrase_env_paths() {
let _g = env_lock();
unsafe {
std::env::set_var("ZKV_PASSPHRASE", "env-secret");
}
let got = read_passphrase(None).unwrap();
assert_eq!(got.as_str(), "env-secret");
let pf = tmp_path("passfile_prec");
std::fs::write(&pf, "file-loses\n").unwrap();
let got = read_passphrase(Some(&pf)).unwrap();
assert_eq!(got.as_str(), "env-secret");
cleanup(&pf);
unsafe {
std::env::remove_var("ZKV_PASSPHRASE");
}
let p = tmp_path("passfile");
std::fs::write(&p, "file-secret\n").unwrap();
let got = read_passphrase(Some(&p)).unwrap();
assert_eq!(got.as_str(), "file-secret");
cleanup(&p);
let p2 = tmp_path("passfile2");
std::fs::write(&p2, "crlf-secret\r\n").unwrap();
let got2 = read_passphrase(Some(&p2)).unwrap();
assert_eq!(got2.as_str(), "crlf-secret");
cleanup(&p2);
}
#[test]
fn read_passphrase_missing_passfile_errors() {
let _g = env_lock();
unsafe {
std::env::remove_var("ZKV_PASSPHRASE");
}
let p = tmp_path("nope_passfile");
cleanup(&p);
assert!(read_passphrase(Some(&p)).is_err());
}
#[test]
fn read_new_passphrase_env_paths() {
let _g = env_lock();
unsafe {
std::env::set_var("ZKV_NEW_PASSPHRASE", "new-env-secret");
}
let got = read_new_passphrase(None).unwrap();
assert_eq!(got.as_str(), "new-env-secret");
let pf = tmp_path("new_pf_prec");
std::fs::write(&pf, "file-loses\n").unwrap();
let got = read_new_passphrase(Some(&pf)).unwrap();
assert_eq!(got.as_str(), "new-env-secret");
cleanup(&pf);
unsafe {
std::env::remove_var("ZKV_NEW_PASSPHRASE");
}
let p = tmp_path("new_pf");
std::fs::write(&p, "new-file-secret\n").unwrap();
let got = read_new_passphrase(Some(&p)).unwrap();
assert_eq!(got.as_str(), "new-file-secret");
cleanup(&p);
}
#[test]
fn run_passwd_roundtrip_and_old_fails() {
let _g = env_lock();
unsafe {
std::env::remove_var("ZKV_PASSPHRASE");
std::env::remove_var("ZKV_NEW_PASSPHRASE");
}
let p = tmp_path("passwd_cli");
cleanup(&p);
let kdf = fast_kdf();
vault::create_with_params(&p, "pw", &kdf).unwrap();
let pf_old = tmp_path("pf_old_bad");
std::fs::write(&pf_old, "wrong\n").unwrap();
let pf_new_dummy = tmp_path("pf_new_dummy");
std::fs::write(&pf_new_dummy, "whatever\n").unwrap();
assert!(matches!(
run_passwd(&p, Some(&pf_old), Some(&pf_new_dummy)),
Err(Error::BadPassphrase)
));
assert!(vault::unlock(&p, "pw").is_ok());
cleanup(&pf_old);
cleanup(&pf_new_dummy);
let pf_old_ok = tmp_path("pf_old_ok");
std::fs::write(&pf_old_ok, "pw\n").unwrap();
let pf_new = tmp_path("pf_new");
std::fs::write(&pf_new, "newpw\n").unwrap();
run_passwd(&p, Some(&pf_old_ok), Some(&pf_new)).unwrap();
assert!(matches!(
vault::unlock(&p, "pw"),
Err(Error::BadPassphrase)
));
assert!(vault::unlock(&p, "newpw").is_ok());
cleanup(&pf_old_ok);
cleanup(&pf_new);
cleanup(&p);
}
#[test]
fn unlocked_save_roundtrips() {
let p = make_vault("save");
let pf = write_passfile("u");
let u = Unlocked::unlock(&p, Some(&pf)).unwrap();
assert!(u.save().is_ok());
let pf2 = write_passfile("u2");
let u2 = Unlocked::unlock(&p, Some(&pf2)).unwrap();
let items = search::search(u2.db.conn(), &Filter::default()).unwrap();
assert_eq!(items.len(), 2);
cleanup(&p);
}
#[test]
fn run_add_inserts_and_persists() {
let p = make_vault("add");
let pf = write_passfile("u");
let u = Unlocked::unlock(&p, Some(&pf)).unwrap();
let data = r#"{"type":"password","username":"bob","password":"pw1","url":"https://x","totp_secret":"","notes":""}"#;
let id = run_add(&u, "Server", "password", Some(data), &EditFields::default(), vec!["ops".into()], true, None, None).unwrap();
assert!(id > 0);
let got = store::get_item(u.db.conn(), id).unwrap().unwrap();
assert_eq!(got.title, "Server");
assert_eq!(got.template_id, "password");
assert!(got.favorite);
assert_eq!(got.tags, vec!["ops".to_string()]);
assert_eq!(item_field(&got, "username").unwrap(), "bob");
drop(u);
let pf2 = write_passfile("u2");
let u2 = Unlocked::unlock(&p, Some(&pf2)).unwrap();
let got2 = store::get_item(u2.db.conn(), id).unwrap().unwrap();
assert_eq!(got2.title, "Server");
assert_eq!(got2.template_id, "password");
let items = search::search(u2.db.conn(), &Filter::default()).unwrap();
assert_eq!(items.len(), 3);
cleanup(&p);
}
#[test]
fn run_add_bad_data_json_errors() {
let p = make_vault("addbad");
let pf = write_passfile("u");
let u = Unlocked::unlock(&p, Some(&pf)).unwrap();
assert!(run_add(&u, "X", "password", Some("{ not json"), &EditFields::default(), vec![], false, None, None).is_err());
cleanup(&p);
}
#[test]
fn run_edit_updates_fields_and_type() {
let p = make_vault("edit");
let pf = write_passfile("u");
let u = Unlocked::unlock(&p, Some(&pf)).unwrap();
let new_data =
r#"{"type":"note","format":"text","content":"moved"}"#;
run_edit(
&u,
1,
Some("Renamed"),
Some(new_data),
Some(vec!["archive".into()]),
Some(false),
None,
&EditFields::default(),
&TagDelta::default(),
None,
)
.unwrap();
let got = store::get_item(u.db.conn(), 1).unwrap().unwrap();
assert_eq!(got.title, "Renamed");
assert_eq!(got.template_id, "note"); assert_eq!(got.tags, vec!["archive".to_string()]);
assert!(!got.favorite);
assert_eq!(item_field(&got, "content").unwrap(), "moved");
drop(u);
let pf2 = write_passfile("u2");
let u2 = Unlocked::unlock(&p, Some(&pf2)).unwrap();
let got2 = store::get_item(u2.db.conn(), 1).unwrap().unwrap();
assert_eq!(got2.title, "Renamed");
assert_eq!(got2.template_id, "note");
cleanup(&p);
}
#[test]
fn run_edit_partial_title_only() {
let p = make_vault("edit2");
let pf = write_passfile("u");
let u = Unlocked::unlock(&p, Some(&pf)).unwrap();
run_edit(&u, 1, Some("JustTitle"), None, None, None, None, &EditFields::default(), &TagDelta::default(), None).unwrap();
let got = store::get_item(u.db.conn(), 1).unwrap().unwrap();
assert_eq!(got.title, "JustTitle");
assert_eq!(got.template_id, "password");
cleanup(&p);
}
#[test]
fn run_edit_nothing_to_change_errors() {
let p = make_vault("editnone");
let pf = write_passfile("u");
let u = Unlocked::unlock(&p, Some(&pf)).unwrap();
let err = run_edit(&u, 1, None, None, None, None, None, &EditFields::default(), &TagDelta::default(), None);
assert!(matches!(err, Err(Error::Other(_))));
}
#[test]
fn run_edit_missing_item_errors() {
let p = make_vault("editmissing");
let pf = write_passfile("u");
let u = Unlocked::unlock(&p, Some(&pf)).unwrap();
let err = run_edit(&u, 9999, Some("x"), None, None, None, None, &EditFields::default(), &TagDelta::default(), None);
assert!(matches!(err, Err(Error::Other(_))));
}
#[test]
fn run_rm_yes_deletes() {
let p = make_vault("rmyes");
let pf = write_passfile("u");
let u = Unlocked::unlock(&p, Some(&pf)).unwrap();
run_rm(&u, 1, true).unwrap();
assert!(store::get_item(u.db.conn(), 1).unwrap().is_none());
drop(u);
let pf2 = write_passfile("u2");
let u2 = Unlocked::unlock(&p, Some(&pf2)).unwrap();
assert!(store::get_item(u2.db.conn(), 1).unwrap().is_none());
let items = search::search(u2.db.conn(), &Filter::default()).unwrap();
assert_eq!(items.len(), 1);
cleanup(&p);
}
#[test]
fn run_rm_missing_item_errors() {
let p = make_vault("rmmissing");
let pf = write_passfile("u");
let u = Unlocked::unlock(&p, Some(&pf)).unwrap();
let err = run_rm(&u, 9999, true);
assert!(matches!(err, Err(Error::Other(_))));
cleanup(&p);
}
#[test]
fn run_cat_add_ls_rm_roundtrip() {
let p = make_vault("cat");
let pf = write_passfile("u");
let u = Unlocked::unlock(&p, Some(&pf)).unwrap();
let cid = run_cat_add(&u, "Personal", None).unwrap();
assert!(cid > 0);
let sub = run_cat_add(&u, "Banking", Some("Personal")).unwrap();
assert!(sub > 0);
assert!(run_cat_ls(&u).is_ok());
let cats = store::list_categories(u.db.conn()).unwrap();
assert_eq!(cats.len(), 2);
assert!(cats.iter().any(|c| c.name == "Personal"));
assert!(cats.iter().any(|c| c.name == "Banking" && c.parent_id == Some(cid)));
run_cat_rm(&u, "Banking").unwrap();
assert_eq!(store::list_categories(u.db.conn()).unwrap().len(), 1);
run_cat_rm(&u, &cid.to_string()).unwrap();
assert!(store::list_categories(u.db.conn()).unwrap().is_empty());
assert!(run_cat_rm(&u, "Nope").is_err());
drop(u);
let pf2 = write_passfile("u2");
let u2 = Unlocked::unlock(&p, Some(&pf2)).unwrap();
assert!(store::list_categories(u2.db.conn()).unwrap().is_empty());
cleanup(&p);
}
#[test]
fn run_cat_add_bad_parent_errors() {
let p = make_vault("catbad");
let pf = write_passfile("u");
let u = Unlocked::unlock(&p, Some(&pf)).unwrap();
assert!(run_cat_add(&u, "X", Some("Nope")).is_err());
cleanup(&p);
}
#[test]
fn run_tag_ls_rm_mv_roundtrip() {
let p = make_vault("tag");
let pf = write_passfile("u");
let u = Unlocked::unlock(&p, Some(&pf)).unwrap();
assert!(run_tag_ls(&u).is_ok());
let tags = store::list_tags(u.db.conn()).unwrap();
assert!(tags.iter().any(|t| t.name == "work"));
assert!(tags.iter().any(|t| t.name == "vip"));
run_tag_mv(&u, "work", "work2").unwrap();
let tags = store::list_tags(u.db.conn()).unwrap();
assert!(tags.iter().any(|t| t.name == "work2"));
assert!(!tags.iter().any(|t| t.name == "work"));
run_tag_rm(&u, "work2").unwrap();
let tags = store::list_tags(u.db.conn()).unwrap();
assert!(!tags.iter().any(|t| t.name == "work2"));
assert!(run_tag_rm(&u, "ghost").is_err());
assert!(run_tag_mv(&u, "ghost", "x").is_err());
drop(u);
let pf2 = write_passfile("u2");
let u2 = Unlocked::unlock(&p, Some(&pf2)).unwrap();
let tags2 = store::list_tags(u2.db.conn()).unwrap();
assert!(tags2.iter().any(|t| t.name == "vip"));
assert!(!tags2.iter().any(|t| t.name == "work2"));
cleanup(&p);
}
#[test]
fn run_edit_sets_category() {
let p = make_vault("editcat");
let pf = write_passfile("u");
let u = Unlocked::unlock(&p, Some(&pf)).unwrap();
let cid = run_cat_add(&u, "Work", None).unwrap();
run_edit(&u, 1, None, None, None, None, Some("Work"), &EditFields::default(), &TagDelta::default(), None).unwrap();
let got = store::get_item(u.db.conn(), 1).unwrap().unwrap();
assert_eq!(got.category_id, Some(cid));
assert!(run_edit(&u, 1, None, None, None, None, Some("Ghost"), &EditFields::default(), &TagDelta::default(), None).is_err());
cleanup(&p);
}
#[test]
fn run_ls_favorite_only_returns_favorites() {
let p = make_vault("lsfav");
let pf = write_passfile("u");
let u = Unlocked::unlock(&p, Some(&pf)).unwrap();
let data = r#"{"type":"password","username":"bob","password":"pw","url":"","totp_secret":"","notes":""}"#;
let _fav_id = run_add(&u, "Fav", "password", Some(data), &EditFields::default(), vec![], true, None, None).unwrap();
let f = ListFilter::default();
let sf_all = to_search_filter(&f, None);
let all = search::search(u.db.conn(), &sf_all).unwrap();
assert_eq!(all.len(), 3);
assert!(run_ls(&u, &f, true, false).is_ok());
let mut f2 = f.clone();
f2.favorite_only = true;
let sf_fav = to_search_filter(&f2, None);
let favs = search::search(u.db.conn(), &sf_fav).unwrap();
assert_eq!(favs.len(), 1);
assert!(favs.iter().all(|i| i.favorite));
cleanup(&p);
}
fn write_att_file(name: &str, bytes: &[u8]) -> PathBuf {
let mut p = std::env::temp_dir();
p.push(format!("zkv_att_{}_{}", std::process::id(), name));
std::fs::write(&p, bytes).unwrap();
p
}
fn count_attachments(conn: &rusqlite::Connection, item_id: i64) -> i64 {
conn.query_row(
"SELECT COUNT(*) FROM attachments WHERE item_id = ?1",
rusqlite::params![item_id],
|r| r.get::<_, i64>(0),
)
.unwrap()
}
#[test]
fn guess_mime_known_and_unknown() {
assert_eq!(
guess_mime(Path::new("a.pdf")).as_deref(),
Some("application/pdf")
);
assert_eq!(
guess_mime(Path::new("b.PNG")).as_deref(),
Some("image/png")
);
assert_eq!(
guess_mime(Path::new("c.jpeg")).as_deref(),
Some("image/jpeg")
);
assert_eq!(
guess_mime(Path::new("d.json")).as_deref(),
Some("application/json")
);
assert_eq!(
guess_mime(Path::new("e.docx")).as_deref(),
Some("application/vnd.openxmlformats-officedocument.wordprocessingml.document")
);
assert_eq!(guess_mime(Path::new("f.xyz")), None);
assert_eq!(guess_mime(Path::new("noext")), None);
}
#[test]
fn attachment_belongs_to_logic() {
let p = make_vault("att_belongs");
let pf = write_passfile("u");
let u = Unlocked::unlock(&p, Some(&pf)).unwrap();
let conn = u.db.conn();
let mut att = Attachment {
id: None,
item_id: 1,
filename: "x.bin".into(),
mime_type: None,
size: 0,
blob: vec![1, 2, 3],
};
let aid = store::insert_attachment(conn, &mut att).unwrap();
assert!(attachment_belongs_to(conn, aid, 1).unwrap());
assert!(!attachment_belongs_to(conn, aid, 2).unwrap());
assert!(!attachment_belongs_to(conn, 99999, 1).unwrap());
cleanup(&p);
}
#[test]
fn run_attach_add_ls_get_rm_roundtrip() {
let p = make_vault("att_flow");
let pf = write_passfile("u");
let u = Unlocked::unlock(&p, Some(&pf)).unwrap();
let blob = b"\x00hello-attachment\xff".to_vec();
let att_file = write_att_file("src.txt", &blob);
let aid = run_attach_add(&u, 1, &att_file, Some("text/plain")).unwrap();
assert!(aid > 0);
assert_eq!(count_attachments(u.db.conn(), 1), 1);
let got = store::get_attachment(u.db.conn(), aid).unwrap().unwrap();
let basename = att_file
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("");
assert_eq!(got.filename, basename);
assert_eq!(got.size, blob.len() as i64);
assert_eq!(got.mime_type.as_deref(), Some("text/plain"));
assert_eq!(got.blob, blob);
cleanup(&att_file);
assert!(run_attach_ls(&u, 1).is_ok());
assert_eq!(count_attachments(u.db.conn(), 1), 1);
assert_eq!(count_attachments(u.db.conn(), 2), 0);
let out = tmp_path("att_out");
run_attach_get(&u, 1, aid, Some(&out)).unwrap();
let read_back = std::fs::read(&out).unwrap();
assert_eq!(read_back, blob);
cleanup(&out);
assert!(matches!(
run_attach_get(&u, 2, aid, None),
Err(Error::Other(_))
));
assert!(matches!(
run_attach_get(&u, 1, 99999, None),
Err(Error::Other(_))
));
run_attach_rm(&u, 1, aid).unwrap();
assert_eq!(count_attachments(u.db.conn(), 1), 0);
assert!(matches!(
run_attach_rm(&u, 1, aid),
Err(Error::Other(_))
));
drop(u);
let pf2 = write_passfile("u2");
let u2 = Unlocked::unlock(&p, Some(&pf2)).unwrap();
assert_eq!(count_attachments(u2.db.conn(), 1), 0);
cleanup(&p);
}
#[test]
fn run_attach_add_missing_item_errors() {
let p = make_vault("att_baditem");
let pf = write_passfile("u");
let u = Unlocked::unlock(&p, Some(&pf)).unwrap();
let f = write_att_file("x", b"data");
let err = run_attach_add(&u, 9999, &f, None);
assert!(matches!(err, Err(Error::Other(_))));
cleanup(&f);
cleanup(&p);
}
#[test]
fn run_attach_add_missing_file_errors() {
let p = make_vault("att_badfile");
let pf = write_passfile("u");
let u = Unlocked::unlock(&p, Some(&pf)).unwrap();
let f = tmp_path("does_not_exist");
cleanup(&f);
assert!(run_attach_add(&u, 1, &f, None).is_err());
cleanup(&p);
}
#[test]
fn run_attach_ls_empty_prints_no_attachments() {
let p = make_vault("att_ls_empty");
let pf = write_passfile("u");
let u = Unlocked::unlock(&p, Some(&pf)).unwrap();
assert!(run_attach_ls(&u, 1).is_ok());
assert_eq!(count_attachments(u.db.conn(), 1), 0);
cleanup(&p);
}
#[test]
fn run_attach_add_guesses_mime_from_extension() {
let p = make_vault("att_mime");
let pf = write_passfile("u");
let u = Unlocked::unlock(&p, Some(&pf)).unwrap();
let f = write_att_file("data.json", b"{}");
let aid = run_attach_add(&u, 1, &f, None).unwrap();
let got = store::get_attachment(u.db.conn(), aid).unwrap().unwrap();
assert_eq!(got.mime_type.as_deref(), Some("application/json"));
cleanup(&f);
cleanup(&p);
}
fn is_alnum(c: char) -> bool {
c.is_ascii_alphanumeric()
}
fn is_symbol(c: char) -> bool {
"!@#$%^&*()-_=+[]{};:,.?/".contains(c)
}
fn is_ambiguous(c: char) -> bool {
"0Oo1lI|5S2ZB8".contains(c)
}
#[test]
fn generate_password_default_length_and_charset() {
let pw = generate_password(20, true, false).unwrap();
assert_eq!(pw.len(), 20);
assert!(pw.chars().all(|c| is_alnum(c) || is_symbol(c)));
}
#[test]
fn generate_password_no_symbols() {
let pw = generate_password(40, false, false).unwrap();
assert_eq!(pw.len(), 40);
assert!(pw.chars().all(is_alnum));
assert!(!pw.chars().any(is_symbol));
}
#[test]
fn generate_password_no_ambiguous() {
let pw = generate_password(40, true, true).unwrap();
assert_eq!(pw.len(), 40);
assert!(!pw.chars().any(is_ambiguous));
}
#[test]
fn generate_password_length_respected() {
for &len in &[4usize, 5, 16, 32, 100] {
let pw = generate_password(len, true, false).unwrap();
assert_eq!(pw.len(), len);
}
}
#[test]
fn generate_password_two_runs_differ() {
let a = generate_password(32, true, false).unwrap();
let b = generate_password(32, true, false).unwrap();
assert_ne!(a, b);
}
#[test]
fn generate_password_too_short_errors() {
assert!(matches!(
generate_password(3, true, false),
Err(Error::Other(m)) if m.contains("too short")
));
}
#[test]
fn generate_password_too_long_errors() {
assert!(matches!(
generate_password(2000, true, false),
Err(Error::Other(m)) if m.contains("too long")
));
}
#[test]
fn run_gen_prints_password() {
assert!(run_gen(16, true, false).is_ok());
assert!(run_gen(8, false, true).is_ok());
}
#[test]
fn run_add_gen_password_overrides_field() {
let p = make_vault("addgen");
let pf = write_passfile("u");
let u = Unlocked::unlock(&p, Some(&pf)).unwrap();
let data = r#"{"type":"password","username":"bob","password":"old","url":"https://x","totp_secret":"","notes":""}"#;
let id = run_add(&u, "Gen", "password", Some(data), &EditFields::default(), vec![], false, Some(12), None).unwrap();
assert!(id > 0);
let got = store::get_item(u.db.conn(), id).unwrap().unwrap();
let pw = item_field(&got, "password").unwrap();
assert_eq!(pw.len(), 12);
assert_ne!(pw, "old");
assert!(pw.chars().all(|c| is_alnum(c) || is_symbol(c)));
assert!(!pw.chars().any(is_ambiguous));
cleanup(&p);
}
#[test]
fn run_add_gen_password_non_password_errors() {
let p = make_vault("addgennp");
let pf = write_passfile("u");
let u = Unlocked::unlock(&p, Some(&pf)).unwrap();
let data = r#"{"type":"note","format":"text","content":"hi"}"#;
let err = run_add(&u, "N", "password", Some(data), &EditFields::default(), vec![], false, Some(20), None);
assert!(matches!(err, Err(Error::Other(m)) if m.contains("--gen-password")));
cleanup(&p);
}
fn sample_items() -> Vec<Item> {
vec![
Item {
id: Some(1),
template_id: "password".into(),
title: "GitHub".into(),
category_id: None,
fields: vec![
Field { name: "username".into(), value: "alice".into(), kind: FieldKind::Text, protected: false },
Field { name: "password".into(), value: "p,ass\"word".into(), kind: FieldKind::Secret, protected: true },
Field { name: "url".into(), value: "https://github.com".into(), kind: FieldKind::Text, protected: false },
Field { name: "totp".into(), value: "JBSWY3DPEHPK3PXP".into(), kind: FieldKind::Totp, protected: true },
Field { name: "notes".into(), value: "main\nline2".into(), kind: FieldKind::Multiline, protected: false },
],
favorite: true,
tags: vec!["work".into(), "vip".into()],
created_at: 1_700_000_000,
updated_at: 1_700_000_100,
},
Item {
id: Some(2),
template_id: "note".into(),
title: "Ideas".into(),
category_id: None,
fields: vec![
Field { name: "format".into(), value: "markdown".into(), kind: FieldKind::Text, protected: false },
Field { name: "content".into(), value: "hello".into(), kind: FieldKind::Multiline, protected: false },
],
favorite: false,
tags: vec![],
created_at: 0,
updated_at: 0,
},
Item {
id: Some(3),
template_id: "password".into(),
title: "Bank".into(),
category_id: None,
fields: vec![
Field { name: "username".into(), value: "bob".into(), kind: FieldKind::Text, protected: false },
Field { name: "password".into(), value: "plain".into(), kind: FieldKind::Secret, protected: true },
Field { name: "url".into(), value: "".into(), kind: FieldKind::Text, protected: false },
Field { name: "totp".into(), value: "".into(), kind: FieldKind::Totp, protected: true },
Field { name: "notes".into(), value: "".into(), kind: FieldKind::Multiline, protected: false },
],
favorite: false,
tags: vec!["finance".into()],
created_at: 0,
updated_at: 0,
},
]
}
#[test]
fn json_roundtrip_lossless() {
let items = sample_items();
let json = export_json(&items).unwrap();
let (back, atts) = import_json(&json).unwrap();
assert_eq!(back.len(), items.len());
assert!(atts.is_none());
for (a, b) in items.iter().zip(back.iter()) {
assert_eq!(a.title, b.title);
assert_eq!(a.template_id, b.template_id);
assert_eq!(a.fields, b.fields);
assert_eq!(a.tags, b.tags);
assert_eq!(a.favorite, b.favorite);
}
}
#[test]
fn import_json_bad_overall_errors() {
assert!(import_json("{ not json").is_err());
assert!(import_json("not an array").is_err());
let (items, atts) = import_json("[]").unwrap();
assert!(items.is_empty());
assert!(atts.is_none());
}
#[test]
fn csv_export_passwords_only_and_escapes() {
let items = sample_items();
let (csv, skipped) = export_csv(&items);
assert_eq!(skipped, 1);
assert!(csv.starts_with("title,username,password"));
assert!(csv.contains("\"p,ass\"\"word\""));
assert!(csv.contains("\"main\nline2\""));
assert!(csv.contains("work;vip"));
let (back, fail) = import_csv(&csv).unwrap();
assert_eq!(fail, 0);
assert_eq!(back.len(), 2);
}
#[test]
fn csv_roundtrip_password_fields() {
let items = sample_items();
let (csv, _) = export_csv(&items);
let (back, fail) = import_csv(&csv).unwrap();
assert_eq!(fail, 0);
assert_eq!(back.len(), 2);
assert!(back.iter().all(|i| i.template_id == "password"));
let gh = back.iter().find(|i| i.title == "GitHub").unwrap();
assert_eq!(item_field(gh, "username").unwrap(), "alice");
assert_eq!(item_field(gh, "password").unwrap(), "p,ass\"word");
assert_eq!(item_field(gh, "notes").unwrap(), "main\nline2");
assert_eq!(gh.tags, vec!["work".to_string(), "vip".to_string()]);
}
#[test]
fn csv_import_skips_bad_rows() {
let csv = "title,username,password\nok,u,p\n\n";
let (items, fail) = import_csv(csv).unwrap();
assert_eq!(items.len(), 1);
assert_eq!(items[0].title, "ok");
assert_eq!(fail, 1);
}
#[test]
fn csv_import_missing_title_header_errors() {
let csv = "username,password\nu,p\n";
assert!(import_csv(csv).is_err());
let (items, fail) = import_csv("").unwrap();
assert!(items.is_empty());
assert_eq!(fail, 0);
}
#[test]
fn csv_import_missing_columns_default_empty() {
let csv = "title\nOnly\n";
let (items, fail) = import_csv(csv).unwrap();
assert_eq!(fail, 0);
assert_eq!(items.len(), 1);
assert_eq!(items[0].title, "Only");
assert_eq!(item_field(&items[0], "password").unwrap(), "");
assert!(items[0].tags.is_empty());
}
#[test]
fn export_import_json_end_to_end() {
let p = make_vault("exp_json");
let pf = write_passfile("u");
let u = Unlocked::unlock(&p, Some(&pf)).unwrap();
let src = store::list_items(u.db.conn()).unwrap();
assert_eq!(src.len(), 2);
let json = export_json(&src).unwrap();
drop(u);
let p2 = tmp_path("exp_json_dst");
cleanup(&p2);
vault::create_with_params(&p2, "pw", &fast_kdf()).unwrap();
let pf2 = write_passfile("u2");
let u2 = Unlocked::unlock(&p2, Some(&pf2)).unwrap();
let inp = tmp_path("exp_json_in");
std::fs::write(&inp, json).unwrap();
let res = run_import(&u2, Format::Json, Some(&inp)).unwrap();
assert_eq!(res.ok, 2);
assert_eq!(res.fail, 0);
cleanup(&inp);
let dst = store::list_items(u2.db.conn()).unwrap();
assert_eq!(dst.len(), 2);
let gh = dst.iter().find(|i| i.title == "GitHub").unwrap();
assert_eq!(gh.template_id, "password");
assert_eq!(item_field(gh, "username").unwrap(), "alice");
assert_eq!(gh.tags, vec!["vip".to_string(), "work".to_string()]);
assert!(gh.id.is_some());
cleanup(&p);
cleanup(&p2);
}
#[test]
fn export_import_csv_end_to_end() {
let p = make_vault("exp_csv");
let pf = write_passfile("u");
let u = Unlocked::unlock(&p, Some(&pf)).unwrap();
let src = store::list_items(u.db.conn()).unwrap();
let (csv, skipped) = export_csv(&src);
assert_eq!(skipped, 1); drop(u);
let p2 = tmp_path("exp_csv_dst");
cleanup(&p2);
vault::create_with_params(&p2, "pw", &fast_kdf()).unwrap();
let pf2 = write_passfile("u2");
let u2 = Unlocked::unlock(&p2, Some(&pf2)).unwrap();
let inp = tmp_path("exp_csv_in");
std::fs::write(&inp, csv).unwrap();
let res = run_import(&u2, Format::Csv, Some(&inp)).unwrap();
assert_eq!(res.ok, 1);
assert_eq!(res.fail, 0);
cleanup(&inp);
let dst = store::list_items(u2.db.conn()).unwrap();
assert_eq!(dst.len(), 1);
assert_eq!(dst[0].title, "GitHub");
assert_eq!(item_field(&dst[0], "username").unwrap(), "alice");
cleanup(&p);
cleanup(&p2);
}
#[test]
fn run_import_json_bad_data_counts_failed() {
let p = make_vault("imp_bad");
let pf = write_passfile("u");
let u = Unlocked::unlock(&p, Some(&pf)).unwrap();
let inp = tmp_path("imp_bad_in");
std::fs::write(&inp, "{ not valid json array").unwrap();
let res = run_import(&u, Format::Json, Some(&inp)).unwrap();
assert_eq!(res.ok, 0);
assert_eq!(res.fail, 1);
cleanup(&inp);
cleanup(&p);
}
#[test]
fn run_import_csv_partial_success() {
let p = make_vault("imp_csv_partial");
let pf = write_passfile("u");
let u = Unlocked::unlock(&p, Some(&pf)).unwrap();
let inp = tmp_path("imp_csv_in");
std::fs::write(&inp, "title,username,password\nGood,u,p\n\n").unwrap();
let res = run_import(&u, Format::Csv, Some(&inp)).unwrap();
assert_eq!(res.ok, 1);
assert_eq!(res.fail, 1);
cleanup(&inp);
let dst = store::list_items(u.db.conn()).unwrap();
assert_eq!(dst.len(), 3);
assert!(dst.iter().any(|i| i.title == "Good"));
cleanup(&p);
}
#[test]
fn build_backup_json_has_envelope_shape() {
let items = sample_items();
let atts = vec![Attachment {
id: Some(99),
item_id: 1,
filename: "a.bin".into(),
mime_type: Some("application/octet-stream".into()),
size: 3,
blob: vec![0x01, 0x02, 0x03],
}];
let json = build_backup_json(&items, &atts).unwrap();
let v: serde_json::Value = serde_json::from_str(&json).unwrap();
assert!(v.get("items").unwrap().is_array());
assert_eq!(v["items"].as_array().unwrap().len(), items.len());
let a = v.get("attachments").unwrap().as_array().unwrap();
assert_eq!(a.len(), 1);
assert_eq!(a[0]["filename"], "a.bin");
assert_eq!(a[0]["item_id"], 1);
assert_eq!(a[0]["blob"].as_array().unwrap().len(), 3);
}
#[test]
fn backup_json_roundtrip_preserves_attachment_blob() {
let p = make_vault("backup_rt");
let pf = write_passfile("u");
let u = Unlocked::unlock(&p, Some(&pf)).unwrap();
let known_blob = vec![0xDE, 0xAD, 0xBE, 0xEF, 0xCA, 0xFE];
let mut att = Attachment {
id: None,
item_id: 1, filename: "secret.bin".into(),
mime_type: Some("application/octet-stream".into()),
size: 0,
blob: known_blob.clone(),
};
store::insert_attachment(u.db.conn(), &mut att).unwrap();
u.save().unwrap();
drop(u);
let u = Unlocked::unlock(&p, Some(&pf)).unwrap();
let items = store::list_items(u.db.conn()).unwrap();
let atts = store::list_all_attachments(u.db.conn()).unwrap();
assert_eq!(atts.len(), 1);
let json = build_backup_json(&items, &atts).unwrap();
drop(u);
let p2 = tmp_path("backup_rt_dst");
cleanup(&p2);
vault::create_with_params(&p2, "pw", &fast_kdf()).unwrap();
let pf2 = write_passfile("u2");
let u2 = Unlocked::unlock(&p2, Some(&pf2)).unwrap();
let inp = tmp_path("backup_rt_in");
std::fs::write(&inp, json).unwrap();
let res = run_import(&u2, Format::Json, Some(&inp)).unwrap();
assert_eq!(res.ok, 2); assert_eq!(res.fail, 0);
cleanup(&inp);
let dst = store::list_items(u2.db.conn()).unwrap();
let gh = dst.iter().find(|i| i.title == "GitHub").unwrap();
let new_gh_id = gh.id.unwrap();
let new_atts = store::list_attachments(u2.db.conn(), new_gh_id).unwrap();
assert_eq!(new_atts.len(), 1);
let na = &new_atts[0];
assert_eq!(na.filename, "secret.bin");
assert_eq!(na.mime_type.as_deref(), Some("application/octet-stream"));
assert_eq!(na.size, known_blob.len() as i64);
assert_eq!(na.blob, known_blob);
assert_eq!(na.item_id, new_gh_id);
cleanup(&p);
cleanup(&p2);
}
#[test]
fn import_json_plain_array_backward_compat() {
let raw = r#"[{"id":5,"template_id":"password","title":"Legacy","category_id":null,"fields":[{"name":"username","value":"u","kind":"text","protected":false}],"favorite":false,"tags":[],"created_at":0,"updated_at":0}]"#;
let (items, atts) = import_json(raw).unwrap();
assert_eq!(items.len(), 1);
assert_eq!(items[0].title, "Legacy");
assert!(atts.is_none(), "裸数组形状应无附件");
}
#[test]
fn import_json_envelope_yields_attachments() {
let raw = r#"{"items":[{"id":1,"template_id":"password","title":"X","category_id":null,"fields":[],"favorite":false,"tags":[],"created_at":0,"updated_at":0}],"attachments":[{"id":null,"item_id":1,"filename":"f.txt","mime_type":null,"size":0,"blob":[65,66]}]}"#;
let (items, atts) = import_json(raw).unwrap();
assert_eq!(items.len(), 1);
let atts = atts.expect("信封应有附件");
assert_eq!(atts.len(), 1);
assert_eq!(atts[0].filename, "f.txt");
assert_eq!(atts[0].blob, vec![0x41, 0x42]);
}
#[test]
fn run_import_envelope_bad_attachment_counts_failed() {
let raw = r#"{"items":[{"id":1,"template_id":"password","title":"H","category_id":null,"fields":[],"favorite":false,"tags":[],"created_at":0,"updated_at":0}],"attachments":[{"id":null,"item_id":1,"filename":"ok.bin","mime_type":null,"size":0,"blob":[1,2]},{"id":null,"item_id":999,"filename":"orphan.bin","mime_type":null,"size":0,"blob":[3,4]}]}"#;
let p = make_vault("imp_env_bad");
let pf = write_passfile("u");
let u = Unlocked::unlock(&p, Some(&pf)).unwrap();
let inp = tmp_path("imp_env_in");
std::fs::write(&inp, raw).unwrap();
let res = run_import(&u, Format::Json, Some(&inp)).unwrap();
assert_eq!(res.ok, 1); assert_eq!(res.fail, 1); cleanup(&inp);
let dst = store::list_items(u.db.conn()).unwrap();
let h = dst.iter().find(|i| i.title == "H").unwrap();
let new_id = h.id.unwrap();
let atts = store::list_attachments(u.db.conn(), new_id).unwrap();
assert_eq!(atts.len(), 1);
assert_eq!(atts[0].filename, "ok.bin");
assert_eq!(atts[0].blob, vec![0x01, 0x02]);
cleanup(&p);
}
#[test]
fn run_export_to_file_empty_vault() {
let p = tmp_path("exp_empty");
cleanup(&p);
vault::create_with_params(&p, "pw", &fast_kdf()).unwrap();
let pf = write_passfile("u");
let u = Unlocked::unlock(&p, Some(&pf)).unwrap();
let out = tmp_path("exp_empty_out");
run_export(&u, Format::Json, Some(&out)).unwrap();
let body = std::fs::read_to_string(&out).unwrap();
assert_eq!(body.trim(), "{\"items\":[],\"attachments\":[]}");
cleanup(&out);
cleanup(&p);
}
#[test]
fn run_import_creates_duplicates() {
let p = tmp_path("imp_dup");
cleanup(&p);
vault::create_with_params(&p, "pw", &fast_kdf()).unwrap();
let pf = write_passfile("u");
let u = Unlocked::unlock(&p, Some(&pf)).unwrap();
let inp = tmp_path("imp_dup_in");
std::fs::write(&inp, "title,username,password\nDup,u,p\n").unwrap();
run_import(&u, Format::Csv, Some(&inp)).unwrap();
run_import(&u, Format::Csv, Some(&inp)).unwrap();
let dst = store::list_items(u.db.conn()).unwrap();
assert_eq!(dst.len(), 2);
assert!(dst.iter().all(|i| i.title == "Dup"));
cleanup(&inp);
cleanup(&p);
}
#[test]
fn parse_otpauth_extracts_secret() {
let uri = "otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&issuer=Example&digits=6&period=30";
assert_eq!(parse_otpauth(uri).unwrap(), "JBSWY3DPEHPK3PXP");
}
#[test]
fn parse_otpauth_without_other_params() {
let uri = "otpauth://totp/Foo?secret=JBSWY3DPEHPK3PXP";
assert_eq!(parse_otpauth(uri).unwrap(), "JBSWY3DPEHPK3PXP");
}
#[test]
fn parse_otpauth_bad_prefix_errors() {
assert!(parse_otpauth("https://example.com?secret=x").is_err());
assert!(parse_otpauth("totp/Example?secret=x").is_err());
}
#[test]
fn parse_otpauth_no_secret_errors() {
assert!(parse_otpauth("otpauth://totp/Example?issuer=Example").is_err());
assert!(parse_otpauth("otpauth://totp/Example").is_err());
assert!(parse_otpauth("otpauth://totp/Example?secret=").is_err());
}
#[test]
fn parse_otpauth_percent_decodes_secret() {
let uri = "otpauth://totp/X?secret=AB%3DCD";
assert_eq!(parse_otpauth(uri).unwrap(), "AB=CD");
}
fn make_qr_png(payload: &str) -> Vec<u8> {
let code = qrcode::QrCode::new(payload.as_bytes()).unwrap();
let gray: image::GrayImage = code.render::<image::Luma<u8>>().build();
let dyn_img = image::DynamicImage::ImageLuma8(gray);
let mut buf = Vec::new();
let mut cursor = std::io::Cursor::new(&mut buf);
dyn_img
.write_to(&mut cursor, image::ImageFormat::Png)
.unwrap();
buf
}
#[test]
fn otpauth_from_qr_bytes_roundtrip() {
let uri = "otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&issuer=Example";
let png = make_qr_png(uri);
let got = otpauth_from_qr_bytes(&png).unwrap();
assert_eq!(got, uri);
assert_eq!(parse_otpauth(&got).unwrap(), "JBSWY3DPEHPK3PXP");
}
#[test]
fn otpauth_from_qr_bytes_not_an_image() {
assert!(otpauth_from_qr_bytes(&[0u8; 32]).is_err());
}
#[test]
fn otpauth_from_qr_bytes_wrong_payload() {
let png = make_qr_png("hello world");
assert!(matches!(
otpauth_from_qr_bytes(&png),
Err(Error::Other(m)) if m.contains("no otpauth qr")
));
}
#[test]
fn fetch_url_bytes_rejects_unsupported_scheme() {
assert!(fetch_url_bytes("file:///etc/passwd").is_err());
assert!(fetch_url_bytes("ftp://x/y.png").is_err());
}
#[test]
fn fetch_url_bytes_data_url_base64_full_chain() {
let uri = "otpauth://totp/X?secret=JBSWY3DPEHPK3PXP";
let png = make_qr_png(uri);
let b64 = data_encoding::BASE64.encode(&png);
let data_url = format!("data:image/png;base64,{b64}");
let bytes = fetch_url_bytes(&data_url).unwrap();
assert_eq!(bytes, png);
assert_eq!(otpauth_from_qr_bytes(&bytes).unwrap(), uri);
}
#[test]
fn fetch_url_bytes_data_url_missing_payload() {
assert!(fetch_url_bytes("data:image/png;base64").is_err());
}
#[test]
fn run_add_otpauth_overrides_totp_secret() {
let p = make_vault("addotp");
let pf = write_passfile("u");
let u = Unlocked::unlock(&p, Some(&pf)).unwrap();
let data = r#"{"type":"password","username":"bob","password":"pw","url":"https://x","totp_secret":"","notes":""}"#;
let uri = "otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&issuer=Example";
let id = run_add(&u, "Totp", "password", Some(data), &EditFields::default(), vec![], false, None, Some(uri)).unwrap();
let got = store::get_item(u.db.conn(), id).unwrap().unwrap();
assert_eq!(item_field(&got, "totp").unwrap(), "JBSWY3DPEHPK3PXP");
assert!(otp_of_item(&got).is_ok());
cleanup(&p);
}
#[test]
fn run_add_otpauth_non_password_errors() {
let p = make_vault("addotpnp");
let pf = write_passfile("u");
let u = Unlocked::unlock(&p, Some(&pf)).unwrap();
let data = r#"{"type":"note","format":"text","content":"hi"}"#;
let uri = "otpauth://totp/X?secret=JBSWY3DPEHPK3PXP";
let err = run_add(&u, "N", "password", Some(data), &EditFields::default(), vec![], false, None, Some(uri));
assert!(matches!(err, Err(Error::Other(m)) if m.contains("--otpauth")));
cleanup(&p);
}
#[test]
fn run_add_otpauth_coexists_with_gen_password() {
let p = make_vault("addotpgen");
let pf = write_passfile("u");
let u = Unlocked::unlock(&p, Some(&pf)).unwrap();
let data = r#"{"type":"password","username":"bob","password":"old","url":"","totp_secret":"","notes":""}"#;
let uri = "otpauth://totp/X?secret=JBSWY3DPEHPK3PXP";
let id = run_add(&u, "Both", "password", Some(data), &EditFields::default(), vec![], false, Some(12), Some(uri)).unwrap();
let got = store::get_item(u.db.conn(), id).unwrap().unwrap();
let pw = item_field(&got, "password").unwrap();
assert_eq!(pw.len(), 12);
assert_ne!(pw, "old");
assert_eq!(item_field(&got, "totp").unwrap(), "JBSWY3DPEHPK3PXP");
cleanup(&p);
}
#[test]
fn run_edit_otpauth_overrides_totp_secret() {
let p = make_vault("editotp");
let pf = write_passfile("u");
let u = Unlocked::unlock(&p, Some(&pf)).unwrap();
let uri = "otpauth://totp/X?secret=GEZDGNBVGY3TQOJQ";
run_edit(
&u,
1,
None,
None,
None,
None,
None,
&EditFields::default(),
&TagDelta::default(),
Some(uri),
)
.unwrap();
let got = store::get_item(u.db.conn(), 1).unwrap().unwrap();
assert_eq!(item_field(&got, "totp").unwrap(), "GEZDGNBVGY3TQOJQ");
cleanup(&p);
}
#[test]
fn edit_fields_apply_password() {
let mut item = mk_password_item("u", "p");
let f = EditFields { sets: vec![("username".into(), "bob".into()), ("url".into(), "https://y".into())] };
assert!(f.apply(&mut item));
assert_eq!(item_field(&item, "username").unwrap(), "bob");
assert_eq!(item_field(&item, "url").unwrap(), "https://y");
}
#[test]
fn edit_fields_apply_note_content() {
let mut item = mk_item(
"note",
"",
&[("format", "text", FieldKind::Text), ("content", "old", FieldKind::Multiline)],
);
let f = EditFields { sets: vec![("content".into(), "new body".into())] };
assert!(f.apply(&mut item));
assert_eq!(item_field(&item, "content").unwrap(), "new body");
}
#[test]
fn edit_fields_apply_card_fields() {
let mut item = mk_item(
"card",
"",
&[
("holder", "h", FieldKind::Text),
("number", "n", FieldKind::Secret),
("expiry", "e", FieldKind::Text),
("cvv", "c", FieldKind::Secret),
("bank", "b", FieldKind::Text),
("notes", "", FieldKind::Multiline),
],
);
let f = EditFields { sets: vec![("holder".into(), "HH".into()), ("cvv".into(), "999".into())] };
assert!(f.apply(&mut item));
assert_eq!(item_field(&item, "holder").unwrap(), "HH");
assert_eq!(item_field(&item, "cvv").unwrap(), "999");
}
#[test]
fn run_edit_single_field_username() {
let p = make_vault("editfield");
let pf = write_passfile("u");
let u = Unlocked::unlock(&p, Some(&pf)).unwrap();
let f = EditFields { sets: vec![("username".into(), "bob".into())] };
run_edit(
&u,
1,
None,
None,
None,
None,
None,
&f,
&TagDelta::default(),
None,
)
.unwrap();
let got = store::get_item(u.db.conn(), 1).unwrap().unwrap();
assert_eq!(item_field(&got, "username").unwrap(), "bob");
assert_eq!(item_field(&got, "password").unwrap(), "s3cret");
cleanup(&p);
}
#[test]
fn run_edit_data_conflicts_with_field_flags() {
let p = make_vault("editconf");
let pf = write_passfile("u");
let u = Unlocked::unlock(&p, Some(&pf)).unwrap();
let f = EditFields { sets: vec![("username".into(), "bob".into())] };
let err = run_edit(
&u,
1,
None,
Some(r#"{"type":"note","format":"text","content":"x"}"#),
None,
None,
None,
&f,
&TagDelta::default(),
None,
);
assert!(matches!(err, Err(Error::Other(m)) if m.contains("conflicts with field flags")));
cleanup(&p);
}
#[test]
fn tag_delta_add_dedup_and_remove() {
let d = TagDelta {
add: vec!["c".into()],
remove: vec![],
};
assert_eq!(d.apply(&["a".into(), "b".into()]), vec!["a", "b", "c"]);
assert_eq!(d.apply(&["a".into(), "c".into()]), vec!["a", "c"]);
let d2 = TagDelta {
add: vec![],
remove: vec!["a".into()],
};
assert_eq!(d2.apply(&["a".into(), "b".into()]), vec!["b"]);
}
#[test]
fn run_edit_add_and_rm_tag() {
let p = make_vault("edittagdelta");
let pf = write_passfile("u");
let u = Unlocked::unlock(&p, Some(&pf)).unwrap();
let d = TagDelta {
add: vec!["c".into()],
remove: vec!["a".into()],
};
run_edit(
&u,
1,
None,
None,
None,
None,
None,
&EditFields::default(),
&d,
None,
)
.unwrap();
let got = store::get_item(u.db.conn(), 1).unwrap().unwrap();
assert_eq!(got.tags, vec!["c".to_string(), "vip".to_string(), "work".into()]);
let d2 = TagDelta {
add: vec![],
remove: vec!["vip".into()],
};
run_edit(
&u,
1,
None,
None,
None,
None,
None,
&EditFields::default(),
&d2,
None,
)
.unwrap();
let got = store::get_item(u.db.conn(), 1).unwrap().unwrap();
assert_eq!(got.tags, vec!["c".to_string(), "work".into()]);
cleanup(&p);
}
#[test]
fn run_edit_tag_conflicts_with_delta() {
let p = make_vault("edittagconf");
let pf = write_passfile("u");
let u = Unlocked::unlock(&p, Some(&pf)).unwrap();
let d = TagDelta {
add: vec!["c".into()],
remove: vec![],
};
let err = run_edit(
&u,
1,
None,
None,
Some(vec!["x".into()]), None,
None,
&EditFields::default(),
&d,
None,
);
assert!(matches!(err, Err(Error::Other(m)) if m.contains("--tag conflicts")));
cleanup(&p);
}
#[test]
fn find_item_id_exact_then_prefix() {
let p = make_vault("find");
let pf = write_passfile("u");
let u = Unlocked::unlock(&p, Some(&pf)).unwrap();
let conn = u.db.conn();
assert_eq!(find_item_id_by_title(conn, "GitHub").unwrap(), 1);
assert_eq!(find_item_id_by_title(conn, "Git").unwrap(), 1);
cleanup(&p);
}
#[test]
fn find_item_id_ambiguous_and_none() {
let p = make_vault("findamb");
let pf = write_passfile("u");
let u = Unlocked::unlock(&p, Some(&pf)).unwrap();
let data = r#"{"type":"password","username":"x","password":"p","url":"","totp_secret":"","notes":""}"#;
let _id1 = run_add(&u, "GitLab", "password", Some(data), &EditFields::default(), vec![], false, None, None).unwrap();
let conn = u.db.conn();
let err = find_item_id_by_title(conn, "Git");
assert!(matches!(err, Err(Error::Other(m)) if m.contains("multiple items match 'Git'")));
let err2 = find_item_id_by_title(conn, "NopeXYZ");
assert!(matches!(err2, Err(Error::Other(m)) if m.contains("no item matches")));
cleanup(&p);
}
#[test]
fn resolve_id_prefers_positional_then_find() {
let p = make_vault("resolve");
let pf = write_passfile("u");
let u = Unlocked::unlock(&p, Some(&pf)).unwrap();
let conn = u.db.conn();
assert_eq!(resolve_id(conn, Some(2), Some("GitHub")).unwrap(), 2);
assert_eq!(resolve_id(conn, None, Some("GitHub")).unwrap(), 1);
assert!(resolve_id(conn, None, None).is_err());
cleanup(&p);
}
#[test]
fn run_get_via_find_locates_item() {
let p = make_vault("getfind");
let pf = write_passfile("u");
let u = Unlocked::unlock(&p, Some(&pf)).unwrap();
let conn = u.db.conn();
let id = resolve_id(conn, None, Some("Git")).unwrap();
assert_eq!(id, 1);
assert!(run_get(&u, id, Some("username"), false).is_ok());
cleanup(&p);
}
#[test]
fn default_vault_path_uses_home() {
let _g = env_lock();
let dir = std::env::temp_dir();
let fake_home = dir.join(format!(
"zkv_home_{}_{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
unsafe {
std::env::set_var("HOME", &fake_home);
}
let got = default_vault_path();
unsafe {
std::env::remove_var("HOME");
}
let got = got.unwrap();
assert_eq!(got, fake_home.join(".zkv").join("default.zkv"));
}
#[test]
fn default_vault_path_missing_home_errors() {
let _g = env_lock();
unsafe {
std::env::remove_var("HOME");
std::env::remove_var("USERPROFILE");
}
let got = default_vault_path();
assert!(got.is_err(), "expected error when no home dir available");
}
#[test]
fn resolve_vault_path_some_and_none() {
let _g = env_lock();
unsafe {
std::env::remove_var("HOME");
}
let explicit = PathBuf::from("/tmp/explicit.zkv");
assert_eq!(resolve_vault_path(Some(explicit.clone())).unwrap(), explicit);
let dir = std::env::temp_dir();
let fake_home = dir.join(format!(
"zkv_home2_{}_{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
unsafe {
std::env::set_var("HOME", &fake_home);
}
let r = resolve_vault_path(None).unwrap();
unsafe {
std::env::remove_var("HOME");
}
assert_eq!(r, fake_home.join(".zkv").join("default.zkv"));
}
#[test]
fn default_path_init_then_ls_roundtrip() {
let _g = env_lock();
let root = std::env::temp_dir().join(format!(
"zkv_rt_{}_{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
unsafe {
std::env::set_var("HOME", &root);
std::env::set_var("ZKV_PASSPHRASE", "pw");
}
let res = (|| -> Result<()> {
let p = resolve_vault_path(None)?;
if let Some(parent) = p.parent() {
std::fs::create_dir_all(parent)?;
}
run_init(&p, None)?;
assert!(p.exists());
let u = Unlocked::unlock(&p, None)?;
let items = search::search(u.db.conn(), &Filter::default())?;
assert!(items.is_empty());
Ok(())
})();
unsafe {
std::env::remove_var("HOME");
std::env::remove_var("ZKV_PASSPHRASE");
}
let _ = std::fs::remove_dir_all(&root);
res.unwrap();
}
}