feat(cli): close audit gaps — TOTP edit, history, detach, status, generator defaults
One coherent CLI completeness pass driven by the 2026-04-27 state-of-the-
project audit. All TDD; 6 new integration tests (workspace 158→164).
Stubs and dead state fixed:
- TOTP edit was an explicit stub at main.rs:925 ("delete and re-add for
now"). Now supports editing issuer, label, and rotating the secret;
rotated secrets are pushed to field_history under core:totp_secret.
- VaultSettings.generator_defaults was stored but never read by the CLI.
cmd_generate now consults it when invoked inside an initialized vault;
explicit flags override. Behavior outside a vault unchanged.
New commands:
- relicario settings generator-defaults [--random|--bip39] [--length |
--words | --symbols | --separator] — view/edit the stored generator
defaults.
- relicario history <query> [--show] [--field <name>] — view captured
field history. Values masked by default.
- relicario detach <query> <aid> — remove an individual attachment +
blob. Refuses to drop a Document item's primary attachment.
- relicario status — vault summary: root path, item counts (active /
trashed), attachment count + total bytes, registered device count,
last commit (%h %s).
Internal refactor (pure mechanical, no behavior change):
- cmd_add: 217-line match split into one build_<type>_item helper per
ItemCore variant + a 7-arm dispatcher.
- cmd_edit: same treatment — edit_login, edit_card, edit_totp, etc. The
history-tracking ones take a &mut FieldHistory alias for clarity.
Existing tests cover the refactor; the new helpers are tested through
the same integration paths.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -67,6 +67,18 @@ enum Commands {
|
|||||||
/// Edit an item interactively.
|
/// Edit an item interactively.
|
||||||
Edit { query: String },
|
Edit { query: String },
|
||||||
|
|
||||||
|
/// View captured field history for an item. Values are masked by
|
||||||
|
/// default; pass `--show` to reveal them.
|
||||||
|
History {
|
||||||
|
query: String,
|
||||||
|
#[arg(long)]
|
||||||
|
show: bool,
|
||||||
|
/// Filter to a single field (matches against the synthetic key,
|
||||||
|
/// e.g. `login_password`, `card_number`, `totp_secret`).
|
||||||
|
#[arg(long)]
|
||||||
|
field: Option<String>,
|
||||||
|
},
|
||||||
|
|
||||||
/// Soft-delete an item (moves to trash; reversible via `restore`).
|
/// Soft-delete an item (moves to trash; reversible via `restore`).
|
||||||
Rm { query: String },
|
Rm { query: String },
|
||||||
|
|
||||||
@@ -96,19 +108,27 @@ enum Commands {
|
|||||||
out: Option<PathBuf>,
|
out: Option<PathBuf>,
|
||||||
},
|
},
|
||||||
|
|
||||||
/// Generate a password or passphrase.
|
/// Remove an individual attachment from an item (deletes the encrypted
|
||||||
|
/// blob and updates the item + manifest). Use `purge` to drop the entire
|
||||||
|
/// item and all its attachments at once.
|
||||||
|
Detach { query: String, aid: String },
|
||||||
|
|
||||||
|
/// Generate a password or passphrase. When run inside an initialized
|
||||||
|
/// vault, falls back to `settings generator-defaults` for unspecified
|
||||||
|
/// flags; outside a vault, uses built-in defaults (length 20, safe
|
||||||
|
/// symbol set, 5 BIP39 words, space separator).
|
||||||
Generate {
|
Generate {
|
||||||
#[arg(long, default_value_t = 20)]
|
#[arg(long)]
|
||||||
length: u32,
|
length: Option<u32>,
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
bip39: bool,
|
bip39: bool,
|
||||||
#[arg(long, default_value_t = 5)]
|
#[arg(long)]
|
||||||
words: u32,
|
words: Option<u32>,
|
||||||
#[arg(long, default_value = "safe")]
|
#[arg(long)]
|
||||||
symbols: String,
|
symbols: Option<String>,
|
||||||
/// Separator for BIP39 words.
|
/// Separator for BIP39 words.
|
||||||
#[arg(long, default_value = " ")]
|
#[arg(long)]
|
||||||
separator: String,
|
separator: Option<String>,
|
||||||
},
|
},
|
||||||
|
|
||||||
/// View or change vault settings.
|
/// View or change vault settings.
|
||||||
@@ -120,6 +140,9 @@ enum Commands {
|
|||||||
/// Sync with the git remote (pull --rebase + push).
|
/// Sync with the git remote (pull --rebase + push).
|
||||||
Sync,
|
Sync,
|
||||||
|
|
||||||
|
/// Print a summary of the vault: items, attachments, devices, last commit.
|
||||||
|
Status,
|
||||||
|
|
||||||
/// Device management.
|
/// Device management.
|
||||||
Device {
|
Device {
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
@@ -222,6 +245,26 @@ enum SettingsAction {
|
|||||||
#[arg(long)] per_vault_soft_cap_bytes: Option<u64>,
|
#[arg(long)] per_vault_soft_cap_bytes: Option<u64>,
|
||||||
#[arg(long)] per_vault_hard_cap_bytes: Option<u64>,
|
#[arg(long)] per_vault_hard_cap_bytes: Option<u64>,
|
||||||
},
|
},
|
||||||
|
/// Update the default password / passphrase generator settings used by
|
||||||
|
/// `relicario generate` when run inside this vault. Pass `--bip39` or
|
||||||
|
/// `--random` to switch mode; per-attribute flags update fields of the
|
||||||
|
/// chosen mode.
|
||||||
|
GeneratorDefaults {
|
||||||
|
/// Switch the default mode to random-character password.
|
||||||
|
#[arg(long, conflicts_with = "bip39")]
|
||||||
|
random: bool,
|
||||||
|
/// Switch the default mode to BIP39 passphrase.
|
||||||
|
#[arg(long, conflicts_with = "random")]
|
||||||
|
bip39: bool,
|
||||||
|
/// Random mode: total password length.
|
||||||
|
#[arg(long)] length: Option<u32>,
|
||||||
|
/// BIP39 mode: number of words.
|
||||||
|
#[arg(long)] words: Option<u32>,
|
||||||
|
/// Random mode: symbol charset (`safe`, `extended`, or a custom literal).
|
||||||
|
#[arg(long)] symbols: Option<String>,
|
||||||
|
/// BIP39 mode: word separator.
|
||||||
|
#[arg(long)] separator: Option<String>,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Subcommand)]
|
#[derive(Subcommand)]
|
||||||
@@ -239,6 +282,7 @@ fn main() -> Result<()> {
|
|||||||
Commands::Get { query, show, copy } => cmd_get(query, show, copy),
|
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::List { r#type, group, tag, trashed } => cmd_list(r#type, group, tag, trashed),
|
||||||
Commands::Edit { query } => cmd_edit(query),
|
Commands::Edit { query } => cmd_edit(query),
|
||||||
|
Commands::History { query, show, field } => cmd_history(query, show, field),
|
||||||
Commands::Rm { query } => cmd_rm(query),
|
Commands::Rm { query } => cmd_rm(query),
|
||||||
Commands::Restore { query } => cmd_restore(query),
|
Commands::Restore { query } => cmd_restore(query),
|
||||||
Commands::Purge { query } => cmd_purge(query),
|
Commands::Purge { query } => cmd_purge(query),
|
||||||
@@ -246,11 +290,13 @@ fn main() -> Result<()> {
|
|||||||
Commands::Attach { query, file } => cmd_attach(query, file),
|
Commands::Attach { query, file } => cmd_attach(query, file),
|
||||||
Commands::Attachments { query } => cmd_attachments(query),
|
Commands::Attachments { query } => cmd_attachments(query),
|
||||||
Commands::Extract { query, aid, out } => cmd_extract(query, aid, out),
|
Commands::Extract { query, aid, out } => cmd_extract(query, aid, out),
|
||||||
|
Commands::Detach { query, aid } => cmd_detach(query, aid),
|
||||||
Commands::Generate { length, bip39, words, symbols, separator } => {
|
Commands::Generate { length, bip39, words, symbols, separator } => {
|
||||||
cmd_generate(length, bip39, words, symbols, separator)
|
cmd_generate(length, bip39, words, symbols, separator)
|
||||||
}
|
}
|
||||||
Commands::Settings { action } => cmd_settings(action),
|
Commands::Settings { action } => cmd_settings(action),
|
||||||
Commands::Sync => cmd_sync(),
|
Commands::Sync => cmd_sync(),
|
||||||
|
Commands::Status => cmd_status(),
|
||||||
Commands::Device { action } => cmd_device(action),
|
Commands::Device { action } => cmd_device(action),
|
||||||
Commands::Lock => { eprintln!("no cached session to lock"); Ok(()) }
|
Commands::Lock => { eprintln!("no cached session to lock"); Ok(()) }
|
||||||
}
|
}
|
||||||
@@ -375,7 +421,56 @@ fn cmd_add(kind: AddKind) -> Result<()> {
|
|||||||
let mut manifest = vault.load_manifest()?;
|
let mut manifest = vault.load_manifest()?;
|
||||||
|
|
||||||
let item = match kind {
|
let item = match kind {
|
||||||
AddKind::Login { title, username, url, password_prompt, password, group, tags, favorite } => {
|
AddKind::Login { title, username, url, password_prompt, password, group, tags, favorite } =>
|
||||||
|
build_login_item(title, username, url, password_prompt, password, group, tags, favorite)?,
|
||||||
|
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 } =>
|
||||||
|
build_identity_item(title, full_name, email, phone, date_of_birth, group, tags)?,
|
||||||
|
AddKind::Card { title, holder, expiry, kind, group, tags } =>
|
||||||
|
build_card_item(title, holder, expiry, kind, group, tags)?,
|
||||||
|
AddKind::Key { title, label, algorithm, group, tags } =>
|
||||||
|
build_key_item(title, label, algorithm, group, tags)?,
|
||||||
|
AddKind::Document { title, file, group, tags } =>
|
||||||
|
build_document_item(&vault, title, file, group, tags)?,
|
||||||
|
AddKind::Totp { title, issuer, label, secret, period, digits, algorithm, group, tags } =>
|
||||||
|
build_totp_item(title, issuer, label, secret, period, digits, algorithm, group, tags)?,
|
||||||
|
};
|
||||||
|
|
||||||
|
vault.save_item(&item)?;
|
||||||
|
manifest.upsert(&item);
|
||||||
|
vault.save_manifest(&manifest)?;
|
||||||
|
|
||||||
|
let mut paths: Vec<String> = vec![
|
||||||
|
format!("items/{}.enc", item.id.as_str()),
|
||||||
|
"manifest.enc".into(),
|
||||||
|
];
|
||||||
|
for att in &item.attachments {
|
||||||
|
paths.push(format!("attachments/{}/{}.enc", item.id.as_str(), att.id.as_str()));
|
||||||
|
}
|
||||||
|
let path_refs: Vec<&str> = paths.iter().map(|s| s.as_str()).collect();
|
||||||
|
commit_paths(&vault, &format!("add: {} ({})", item.title, item.id.as_str()), &path_refs)?;
|
||||||
|
|
||||||
|
eprintln!("Added: {} (id={})", item.title, item.id.as_str());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Per-type item builders, one per AddKind variant. Each returns a
|
||||||
|
// fully-populated Item; cmd_add handles the common save/manifest/commit
|
||||||
|
// wrap-up. Document is the only builder that needs the unlocked vault
|
||||||
|
// (for attachment-cap settings + writing the encrypted blob alongside
|
||||||
|
// the item).
|
||||||
|
|
||||||
|
fn build_login_item(
|
||||||
|
title: Option<String>,
|
||||||
|
username: Option<String>,
|
||||||
|
url: Option<String>,
|
||||||
|
password_prompt: bool,
|
||||||
|
password: Option<String>,
|
||||||
|
group: Option<String>,
|
||||||
|
tags: Vec<String>,
|
||||||
|
favorite: bool,
|
||||||
|
) -> Result<relicario_core::Item> {
|
||||||
use relicario_core::item_types::LoginCore;
|
use relicario_core::item_types::LoginCore;
|
||||||
use relicario_core::{Item, ItemCore};
|
use relicario_core::{Item, ItemCore};
|
||||||
use zeroize::Zeroizing;
|
use zeroize::Zeroizing;
|
||||||
@@ -384,8 +479,7 @@ fn cmd_add(kind: AddKind) -> Result<()> {
|
|||||||
let username = username.or_else(|| prompt_optional("Username").ok().flatten());
|
let username = username.or_else(|| prompt_optional("Username").ok().flatten());
|
||||||
let url = url.or_else(|| prompt_optional("URL").ok().flatten());
|
let url = url.or_else(|| prompt_optional("URL").ok().flatten());
|
||||||
let parsed_url = match url {
|
let parsed_url = match url {
|
||||||
Some(s) => Some(url::Url::parse(&s)
|
Some(s) => Some(url::Url::parse(&s).with_context(|| format!("invalid URL: {s}"))?),
|
||||||
.with_context(|| format!("invalid URL: {s}"))?),
|
|
||||||
None => None,
|
None => None,
|
||||||
};
|
};
|
||||||
let password = if let Some(p) = password {
|
let password = if let Some(p) = password {
|
||||||
@@ -395,19 +489,21 @@ fn cmd_add(kind: AddKind) -> Result<()> {
|
|||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
let core = ItemCore::Login(LoginCore {
|
let mut item = Item::new(title, ItemCore::Login(LoginCore {
|
||||||
username,
|
username, password, url: parsed_url, totp: None,
|
||||||
password,
|
}));
|
||||||
url: parsed_url,
|
|
||||||
totp: None,
|
|
||||||
});
|
|
||||||
let mut item = Item::new(title, core);
|
|
||||||
item.group = group;
|
item.group = group;
|
||||||
item.tags = tags;
|
item.tags = tags;
|
||||||
item.favorite = favorite;
|
item.favorite = favorite;
|
||||||
item
|
Ok(item)
|
||||||
}
|
}
|
||||||
AddKind::SecureNote { title, body_prompt, group, tags } => {
|
|
||||||
|
fn build_secure_note_item(
|
||||||
|
title: Option<String>,
|
||||||
|
body_prompt: bool,
|
||||||
|
group: Option<String>,
|
||||||
|
tags: Vec<String>,
|
||||||
|
) -> Result<relicario_core::Item> {
|
||||||
use relicario_core::item_types::SecureNoteCore;
|
use relicario_core::item_types::SecureNoteCore;
|
||||||
use relicario_core::{Item, ItemCore};
|
use relicario_core::{Item, ItemCore};
|
||||||
use zeroize::Zeroizing;
|
use zeroize::Zeroizing;
|
||||||
@@ -426,10 +522,18 @@ fn cmd_add(kind: AddKind) -> Result<()> {
|
|||||||
}));
|
}));
|
||||||
item.group = group;
|
item.group = group;
|
||||||
item.tags = tags;
|
item.tags = tags;
|
||||||
item
|
Ok(item)
|
||||||
}
|
}
|
||||||
|
|
||||||
AddKind::Identity { title, full_name, email, phone, date_of_birth, group, tags } => {
|
fn build_identity_item(
|
||||||
|
title: Option<String>,
|
||||||
|
full_name: Option<String>,
|
||||||
|
email: Option<String>,
|
||||||
|
phone: Option<String>,
|
||||||
|
date_of_birth: Option<String>,
|
||||||
|
group: Option<String>,
|
||||||
|
tags: Vec<String>,
|
||||||
|
) -> Result<relicario_core::Item> {
|
||||||
use relicario_core::item_types::IdentityCore;
|
use relicario_core::item_types::IdentityCore;
|
||||||
use relicario_core::{Item, ItemCore};
|
use relicario_core::{Item, ItemCore};
|
||||||
|
|
||||||
@@ -440,18 +544,21 @@ fn cmd_add(kind: AddKind) -> Result<()> {
|
|||||||
None => None,
|
None => None,
|
||||||
};
|
};
|
||||||
let mut item = Item::new(title, ItemCore::Identity(IdentityCore {
|
let mut item = Item::new(title, ItemCore::Identity(IdentityCore {
|
||||||
full_name,
|
full_name, address: None, phone, email, date_of_birth: dob,
|
||||||
address: None,
|
|
||||||
phone,
|
|
||||||
email,
|
|
||||||
date_of_birth: dob,
|
|
||||||
}));
|
}));
|
||||||
item.group = group;
|
item.group = group;
|
||||||
item.tags = tags;
|
item.tags = tags;
|
||||||
item
|
Ok(item)
|
||||||
}
|
}
|
||||||
|
|
||||||
AddKind::Card { title, holder, expiry, kind, group, tags } => {
|
fn build_card_item(
|
||||||
|
title: Option<String>,
|
||||||
|
holder: Option<String>,
|
||||||
|
expiry: Option<String>,
|
||||||
|
kind: String,
|
||||||
|
group: Option<String>,
|
||||||
|
tags: Vec<String>,
|
||||||
|
) -> Result<relicario_core::Item> {
|
||||||
use relicario_core::item_types::{CardCore, CardKind};
|
use relicario_core::item_types::{CardCore, CardKind};
|
||||||
use relicario_core::{Item, ItemCore};
|
use relicario_core::{Item, ItemCore};
|
||||||
use zeroize::Zeroizing;
|
use zeroize::Zeroizing;
|
||||||
@@ -477,19 +584,20 @@ fn cmd_add(kind: AddKind) -> Result<()> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let mut item = Item::new(title, ItemCore::Card(CardCore {
|
let mut item = Item::new(title, ItemCore::Card(CardCore {
|
||||||
number: Some(number),
|
number: Some(number), holder, expiry: parsed_expiry, cvv, pin, kind: parsed_kind,
|
||||||
holder,
|
|
||||||
expiry: parsed_expiry,
|
|
||||||
cvv,
|
|
||||||
pin,
|
|
||||||
kind: parsed_kind,
|
|
||||||
}));
|
}));
|
||||||
item.group = group;
|
item.group = group;
|
||||||
item.tags = tags;
|
item.tags = tags;
|
||||||
item
|
Ok(item)
|
||||||
}
|
}
|
||||||
|
|
||||||
AddKind::Key { title, label, algorithm, group, tags } => {
|
fn build_key_item(
|
||||||
|
title: Option<String>,
|
||||||
|
label: Option<String>,
|
||||||
|
algorithm: Option<String>,
|
||||||
|
group: Option<String>,
|
||||||
|
tags: Vec<String>,
|
||||||
|
) -> Result<relicario_core::Item> {
|
||||||
use relicario_core::item_types::KeyCore;
|
use relicario_core::item_types::KeyCore;
|
||||||
use relicario_core::{Item, ItemCore};
|
use relicario_core::{Item, ItemCore};
|
||||||
use zeroize::Zeroizing;
|
use zeroize::Zeroizing;
|
||||||
@@ -503,20 +611,22 @@ fn cmd_add(kind: AddKind) -> Result<()> {
|
|||||||
|
|
||||||
let mut item = Item::new(title, ItemCore::Key(KeyCore {
|
let mut item = Item::new(title, ItemCore::Key(KeyCore {
|
||||||
key_material: Zeroizing::new(key_material),
|
key_material: Zeroizing::new(key_material),
|
||||||
label,
|
label, public_key, algorithm,
|
||||||
public_key,
|
|
||||||
algorithm,
|
|
||||||
}));
|
}));
|
||||||
item.group = group;
|
item.group = group;
|
||||||
item.tags = tags;
|
item.tags = tags;
|
||||||
item
|
Ok(item)
|
||||||
}
|
}
|
||||||
|
|
||||||
AddKind::Document { title, file, group, tags } => {
|
fn build_document_item(
|
||||||
|
vault: &crate::session::UnlockedVault,
|
||||||
|
title: Option<String>,
|
||||||
|
file: PathBuf,
|
||||||
|
group: Option<String>,
|
||||||
|
tags: Vec<String>,
|
||||||
|
) -> Result<relicario_core::Item> {
|
||||||
use relicario_core::item_types::DocumentCore;
|
use relicario_core::item_types::DocumentCore;
|
||||||
use relicario_core::{
|
use relicario_core::{encrypt_attachment, AttachmentRef, Item, ItemCore};
|
||||||
encrypt_attachment, AttachmentRef, Item, ItemCore,
|
|
||||||
};
|
|
||||||
use std::fs;
|
use std::fs;
|
||||||
|
|
||||||
let title = title.map(Ok).unwrap_or_else(|| prompt("Title"))?;
|
let title = title.map(Ok).unwrap_or_else(|| prompt("Title"))?;
|
||||||
@@ -541,20 +651,28 @@ fn cmd_add(kind: AddKind) -> Result<()> {
|
|||||||
item.tags = tags;
|
item.tags = tags;
|
||||||
item.attachments.push(AttachmentRef {
|
item.attachments.push(AttachmentRef {
|
||||||
id: primary_attachment.clone(),
|
id: primary_attachment.clone(),
|
||||||
filename,
|
filename, mime_type,
|
||||||
mime_type,
|
|
||||||
size: bytes.len() as u64,
|
size: bytes.len() as u64,
|
||||||
created: item.created,
|
created: item.created,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Persist the attachment blob before we return from the match arm.
|
|
||||||
let att_dir = vault.root().join("attachments").join(item.id.as_str());
|
let att_dir = vault.root().join("attachments").join(item.id.as_str());
|
||||||
fs::create_dir_all(&att_dir)?;
|
fs::create_dir_all(&att_dir)?;
|
||||||
fs::write(att_dir.join(format!("{}.enc", primary_attachment.as_str())), &enc.bytes)?;
|
fs::write(att_dir.join(format!("{}.enc", primary_attachment.as_str())), &enc.bytes)?;
|
||||||
item
|
Ok(item)
|
||||||
}
|
}
|
||||||
|
|
||||||
AddKind::Totp { title, issuer, label, secret, period, digits, algorithm, group, tags } => {
|
fn build_totp_item(
|
||||||
|
title: Option<String>,
|
||||||
|
issuer: Option<String>,
|
||||||
|
label: Option<String>,
|
||||||
|
secret: Option<String>,
|
||||||
|
period: u32,
|
||||||
|
digits: u8,
|
||||||
|
algorithm: String,
|
||||||
|
group: Option<String>,
|
||||||
|
tags: Vec<String>,
|
||||||
|
) -> Result<relicario_core::Item> {
|
||||||
use relicario_core::item_types::{TotpAlgorithm, TotpConfig, TotpCore, TotpKind};
|
use relicario_core::item_types::{TotpAlgorithm, TotpConfig, TotpCore, TotpKind};
|
||||||
use relicario_core::{Item, ItemCore};
|
use relicario_core::{Item, ItemCore};
|
||||||
use zeroize::Zeroizing;
|
use zeroize::Zeroizing;
|
||||||
@@ -572,7 +690,7 @@ fn cmd_add(kind: AddKind) -> Result<()> {
|
|||||||
other => anyhow::bail!("unknown algorithm: {other}"),
|
other => anyhow::bail!("unknown algorithm: {other}"),
|
||||||
};
|
};
|
||||||
|
|
||||||
let core = TotpCore {
|
let mut item = Item::new(title, ItemCore::Totp(TotpCore {
|
||||||
config: TotpConfig {
|
config: TotpConfig {
|
||||||
secret: Zeroizing::new(secret_bytes),
|
secret: Zeroizing::new(secret_bytes),
|
||||||
algorithm: algo,
|
algorithm: algo,
|
||||||
@@ -580,32 +698,11 @@ fn cmd_add(kind: AddKind) -> Result<()> {
|
|||||||
period_seconds: period,
|
period_seconds: period,
|
||||||
kind: TotpKind::Totp,
|
kind: TotpKind::Totp,
|
||||||
},
|
},
|
||||||
issuer,
|
issuer, label,
|
||||||
label,
|
}));
|
||||||
};
|
|
||||||
let mut item = Item::new(title, ItemCore::Totp(core));
|
|
||||||
item.group = group;
|
item.group = group;
|
||||||
item.tags = tags;
|
item.tags = tags;
|
||||||
item
|
Ok(item)
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
vault.save_item(&item)?;
|
|
||||||
manifest.upsert(&item);
|
|
||||||
vault.save_manifest(&manifest)?;
|
|
||||||
|
|
||||||
let mut paths: Vec<String> = vec![
|
|
||||||
format!("items/{}.enc", item.id.as_str()),
|
|
||||||
"manifest.enc".into(),
|
|
||||||
];
|
|
||||||
for att in &item.attachments {
|
|
||||||
paths.push(format!("attachments/{}/{}.enc", item.id.as_str(), att.id.as_str()));
|
|
||||||
}
|
|
||||||
let path_refs: Vec<&str> = paths.iter().map(|s| s.as_str()).collect();
|
|
||||||
commit_paths(&vault, &format!("add: {} ({})", item.title, item.id.as_str()), &path_refs)?;
|
|
||||||
|
|
||||||
eprintln!("Added: {} (id={})", item.title, item.id.as_str());
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn prompt(label: &str) -> Result<String> {
|
fn prompt(label: &str) -> Result<String> {
|
||||||
@@ -841,7 +938,6 @@ fn cmd_list(
|
|||||||
fn cmd_edit(query: String) -> Result<()> {
|
fn cmd_edit(query: String) -> Result<()> {
|
||||||
use relicario_core::time::now_unix;
|
use relicario_core::time::now_unix;
|
||||||
use relicario_core::ItemCore;
|
use relicario_core::ItemCore;
|
||||||
use zeroize::Zeroizing;
|
|
||||||
|
|
||||||
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||||
let mut manifest = vault.load_manifest()?;
|
let mut manifest = vault.load_manifest()?;
|
||||||
@@ -853,77 +949,21 @@ fn cmd_edit(query: String) -> Result<()> {
|
|||||||
eprintln!("Editing: {} ({}) — leave a prompt blank to keep the current value.",
|
eprintln!("Editing: {} ({}) — leave a prompt blank to keep the current value.",
|
||||||
item.title, item.id.as_str());
|
item.title, item.id.as_str());
|
||||||
|
|
||||||
// Title
|
|
||||||
if let Some(v) = prompt_keep("Title", &item.title)? { item.title = v; }
|
if let Some(v) = prompt_keep("Title", &item.title)? { item.title = v; }
|
||||||
// Group
|
|
||||||
if let Some(v) = prompt_keep_opt("Group", item.group.as_deref())? { item.group = Some(v); }
|
if let Some(v) = prompt_keep_opt("Group", item.group.as_deref())? { item.group = Some(v); }
|
||||||
// Tags (comma-separated)
|
|
||||||
if let Some(v) = prompt_keep_opt("Tags (comma-separated)", Some(&item.tags.join(",")))? {
|
if let Some(v) = prompt_keep_opt("Tags (comma-separated)", Some(&item.tags.join(",")))? {
|
||||||
item.tags = v.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect();
|
item.tags = v.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Core-specific fields. Only Login.password and Card.number/cvv/pin are
|
let history = &mut item.field_history;
|
||||||
// history-tracked from the core path.
|
|
||||||
match &mut item.core {
|
match &mut item.core {
|
||||||
ItemCore::Login(l) => {
|
ItemCore::Login(l) => edit_login(l, history)?,
|
||||||
if let Some(v) = prompt_keep_opt("Username", l.username.as_deref())? { l.username = Some(v); }
|
ItemCore::SecureNote(n) => edit_secure_note(n, history)?,
|
||||||
if let Some(v) = prompt_keep_opt("URL", l.url.as_ref().map(|u| u.as_str()))? {
|
ItemCore::Identity(i) => edit_identity(i)?,
|
||||||
l.url = Some(url::Url::parse(&v).with_context(|| format!("invalid URL: {v}"))?);
|
ItemCore::Card(c) => edit_card(c, history)?,
|
||||||
}
|
ItemCore::Key(k) => edit_key(k, history)?,
|
||||||
if prompt_yesno("Change password?")? {
|
ItemCore::Document(_) => edit_document_message(),
|
||||||
let old = l.password.clone();
|
ItemCore::Totp(t) => edit_totp(t, history)?,
|
||||||
let new_pw = Zeroizing::new(prompt_secret("New password: ")?);
|
|
||||||
l.password = Some(new_pw);
|
|
||||||
if let Some(old_pw) = old {
|
|
||||||
push_history(&mut item.field_history, "login_password",
|
|
||||||
Zeroizing::new(old_pw.as_str().to_string()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ItemCore::SecureNote(n) => {
|
|
||||||
if prompt_yesno("Edit body?")? {
|
|
||||||
let old = n.body.clone();
|
|
||||||
eprintln!("Enter new body; end with Ctrl-D:");
|
|
||||||
let mut s = String::new();
|
|
||||||
std::io::Read::read_to_string(&mut std::io::stdin(), &mut s)?;
|
|
||||||
n.body = Zeroizing::new(s);
|
|
||||||
push_history(&mut item.field_history, "secure_note_body",
|
|
||||||
Zeroizing::new(old.as_str().to_string()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ItemCore::Identity(i) => {
|
|
||||||
if let Some(v) = prompt_keep_opt("Full name", i.full_name.as_deref())? { i.full_name = Some(v); }
|
|
||||||
if let Some(v) = prompt_keep_opt("Email", i.email.as_deref())? { i.email = Some(v); }
|
|
||||||
if let Some(v) = prompt_keep_opt("Phone", i.phone.as_deref())? { i.phone = Some(v); }
|
|
||||||
}
|
|
||||||
ItemCore::Card(c) => {
|
|
||||||
if let Some(v) = prompt_keep_opt("Holder", c.holder.as_deref())? { c.holder = Some(v); }
|
|
||||||
if prompt_yesno("Change card number?")? {
|
|
||||||
let old = c.number.clone();
|
|
||||||
c.number = Some(Zeroizing::new(prompt_secret("New number: ")?));
|
|
||||||
if let Some(o) = old {
|
|
||||||
push_history(&mut item.field_history, "card_number",
|
|
||||||
Zeroizing::new(o.as_str().to_string()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ItemCore::Key(k) => {
|
|
||||||
if prompt_yesno("Replace key material?")? {
|
|
||||||
eprintln!("Paste new key material; end with Ctrl-D:");
|
|
||||||
let mut s = String::new();
|
|
||||||
std::io::Read::read_to_string(&mut std::io::stdin(), &mut s)?;
|
|
||||||
let old = k.key_material.clone();
|
|
||||||
k.key_material = Zeroizing::new(s);
|
|
||||||
push_history(&mut item.field_history, "key_material",
|
|
||||||
Zeroizing::new(old.as_str().to_string()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ItemCore::Document(_) => {
|
|
||||||
eprintln!("Document items: use `relicario attach` / `relicario extract` instead.");
|
|
||||||
}
|
|
||||||
ItemCore::Totp(_) => {
|
|
||||||
eprintln!("TOTP rotation not yet implemented — delete and re-add for now.");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
item.modified = now_unix();
|
item.modified = now_unix();
|
||||||
@@ -936,6 +976,95 @@ fn cmd_edit(query: String) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Per-type edit handlers. Each mutates its core slice in place; the ones
|
||||||
|
// that touch history-tracked fields take the item's field_history map so
|
||||||
|
// they can record the prior value alongside the change.
|
||||||
|
|
||||||
|
type FieldHistory = std::collections::HashMap<
|
||||||
|
relicario_core::FieldId,
|
||||||
|
Vec<relicario_core::item::FieldHistoryEntry>,
|
||||||
|
>;
|
||||||
|
|
||||||
|
fn edit_login(l: &mut relicario_core::item_types::LoginCore, history: &mut FieldHistory) -> Result<()> {
|
||||||
|
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()))? {
|
||||||
|
l.url = Some(url::Url::parse(&v).with_context(|| format!("invalid URL: {v}"))?);
|
||||||
|
}
|
||||||
|
if prompt_yesno("Change password?")? {
|
||||||
|
let old = l.password.clone();
|
||||||
|
l.password = Some(Zeroizing::new(prompt_secret("New password: ")?));
|
||||||
|
if let Some(old_pw) = old {
|
||||||
|
push_history(history, "login_password", Zeroizing::new(old_pw.as_str().to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn edit_secure_note(n: &mut relicario_core::item_types::SecureNoteCore, history: &mut FieldHistory) -> Result<()> {
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
if prompt_yesno("Edit body?")? {
|
||||||
|
let old = n.body.clone();
|
||||||
|
eprintln!("Enter new body; end with Ctrl-D:");
|
||||||
|
let mut s = String::new();
|
||||||
|
std::io::Read::read_to_string(&mut std::io::stdin(), &mut s)?;
|
||||||
|
n.body = Zeroizing::new(s);
|
||||||
|
push_history(history, "secure_note_body", Zeroizing::new(old.as_str().to_string()));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn edit_identity(i: &mut relicario_core::item_types::IdentityCore) -> Result<()> {
|
||||||
|
if let Some(v) = prompt_keep_opt("Full name", i.full_name.as_deref())? { i.full_name = Some(v); }
|
||||||
|
if let Some(v) = prompt_keep_opt("Email", i.email.as_deref())? { i.email = Some(v); }
|
||||||
|
if let Some(v) = prompt_keep_opt("Phone", i.phone.as_deref())? { i.phone = Some(v); }
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn edit_card(c: &mut relicario_core::item_types::CardCore, history: &mut FieldHistory) -> Result<()> {
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
if let Some(v) = prompt_keep_opt("Holder", c.holder.as_deref())? { c.holder = Some(v); }
|
||||||
|
if prompt_yesno("Change card number?")? {
|
||||||
|
let old = c.number.clone();
|
||||||
|
c.number = Some(Zeroizing::new(prompt_secret("New number: ")?));
|
||||||
|
if let Some(o) = old {
|
||||||
|
push_history(history, "card_number", Zeroizing::new(o.as_str().to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn edit_key(k: &mut relicario_core::item_types::KeyCore, history: &mut FieldHistory) -> Result<()> {
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
if prompt_yesno("Replace key material?")? {
|
||||||
|
eprintln!("Paste new key material; end with Ctrl-D:");
|
||||||
|
let mut s = String::new();
|
||||||
|
std::io::Read::read_to_string(&mut std::io::stdin(), &mut s)?;
|
||||||
|
let old = k.key_material.clone();
|
||||||
|
k.key_material = Zeroizing::new(s);
|
||||||
|
push_history(history, "key_material", Zeroizing::new(old.as_str().to_string()));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn edit_document_message() {
|
||||||
|
eprintln!("Document items: use `relicario attach` / `relicario extract` instead.");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn edit_totp(t: &mut relicario_core::item_types::TotpCore, history: &mut FieldHistory) -> Result<()> {
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
if let Some(v) = prompt_keep_opt("Issuer", t.issuer.as_deref())? { t.issuer = Some(v); }
|
||||||
|
if let Some(v) = prompt_keep_opt("Label", t.label.as_deref())? { t.label = Some(v); }
|
||||||
|
if prompt_yesno("Change TOTP secret?")? {
|
||||||
|
let old_b32 = data_encoding::BASE32.encode(&t.config.secret);
|
||||||
|
let new_b32 = prompt_secret("New TOTP secret (base32): ")?;
|
||||||
|
let new_bytes = base32_decode_lenient(&new_b32)?;
|
||||||
|
t.config.secret = Zeroizing::new(new_bytes);
|
||||||
|
push_history(history, "totp_secret", Zeroizing::new(old_b32));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn prompt_keep(label: &str, current: &str) -> Result<Option<String>> {
|
fn prompt_keep(label: &str, current: &str) -> Result<Option<String>> {
|
||||||
eprint!("{label} [{current}]: ");
|
eprint!("{label} [{current}]: ");
|
||||||
std::io::Write::flush(&mut std::io::stderr())?;
|
std::io::Write::flush(&mut std::io::stderr())?;
|
||||||
@@ -978,6 +1107,57 @@ fn push_history(
|
|||||||
replaced_at: now_unix(),
|
replaced_at: now_unix(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn cmd_history(query: String, show: bool, field: Option<String>) -> Result<()> {
|
||||||
|
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||||
|
let manifest = vault.load_manifest()?;
|
||||||
|
let entry = resolve_query(&manifest, &query)?;
|
||||||
|
let item = vault.load_item(&entry.id)?;
|
||||||
|
|
||||||
|
println!("History for {} ({})", item.title, item.id.as_str());
|
||||||
|
println!();
|
||||||
|
|
||||||
|
// Filter and sort the field-id keys so output is deterministic.
|
||||||
|
let mut keys: Vec<&relicario_core::FieldId> = item.field_history.keys().collect();
|
||||||
|
keys.sort_by(|a, b| a.0.cmp(&b.0));
|
||||||
|
|
||||||
|
let mut printed_any = false;
|
||||||
|
for fid in keys {
|
||||||
|
let display_name = fid.0.strip_prefix("core:").unwrap_or(&fid.0);
|
||||||
|
if let Some(filter) = &field {
|
||||||
|
if display_name != filter && fid.0 != *filter { continue; }
|
||||||
|
}
|
||||||
|
let entries = &item.field_history[fid];
|
||||||
|
if entries.is_empty() { continue; }
|
||||||
|
printed_any = true;
|
||||||
|
|
||||||
|
println!("{display_name} ({} {})",
|
||||||
|
entries.len(),
|
||||||
|
if entries.len() == 1 { "entry" } else { "entries" });
|
||||||
|
for (i, e) in entries.iter().enumerate() {
|
||||||
|
let ts = crate::helpers::iso8601(e.replaced_at);
|
||||||
|
if show {
|
||||||
|
println!(" [{i}] {ts} {}", e.value.as_str());
|
||||||
|
} else {
|
||||||
|
println!(" [{i}] {ts} ********");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
|
||||||
|
if !printed_any {
|
||||||
|
if field.is_some() {
|
||||||
|
println!("no history for the requested field");
|
||||||
|
} else {
|
||||||
|
println!("no history captured for this item");
|
||||||
|
}
|
||||||
|
} else if !show {
|
||||||
|
println!("(use --show to reveal values)");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn cmd_rm(query: String) -> Result<()> {
|
fn cmd_rm(query: String) -> Result<()> {
|
||||||
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||||
let mut manifest = vault.load_manifest()?;
|
let mut manifest = vault.load_manifest()?;
|
||||||
@@ -1193,27 +1373,113 @@ fn cmd_extract(query: String, aid: String, out: Option<PathBuf>) -> Result<()> {
|
|||||||
eprintln!("Wrote {} bytes to {}", plaintext.len(), out_path.display());
|
eprintln!("Wrote {} bytes to {}", plaintext.len(), out_path.display());
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
fn cmd_generate(length: u32, bip39: bool, words: u32, symbols: String, separator: String) -> Result<()> {
|
fn cmd_detach(query: String, aid: String) -> Result<()> {
|
||||||
|
use std::fs;
|
||||||
|
use relicario_core::ItemCore;
|
||||||
|
use relicario_core::time::now_unix;
|
||||||
|
|
||||||
|
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||||
|
let mut manifest = vault.load_manifest()?;
|
||||||
|
let entry = resolve_query(&manifest, &query)?;
|
||||||
|
let id = entry.id.clone();
|
||||||
|
let _ = entry;
|
||||||
|
let mut item = vault.load_item(&id)?;
|
||||||
|
|
||||||
|
let pos = item.attachments.iter().position(|a| a.id.as_str() == aid)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("no attachment {aid} on {}", item.title))?;
|
||||||
|
|
||||||
|
// Document items keep their primary blob in the core; refuse to orphan it.
|
||||||
|
if let ItemCore::Document(d) = &item.core {
|
||||||
|
if d.primary_attachment.as_str() == aid {
|
||||||
|
anyhow::bail!(
|
||||||
|
"cannot detach the primary attachment of a Document item; \
|
||||||
|
use `purge {}` to delete the whole item",
|
||||||
|
item.title,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let removed = item.attachments.remove(pos);
|
||||||
|
let blob_path = vault.root().join("attachments").join(item.id.as_str())
|
||||||
|
.join(format!("{}.enc", removed.id.as_str()));
|
||||||
|
if blob_path.exists() {
|
||||||
|
fs::remove_file(&blob_path)
|
||||||
|
.with_context(|| format!("failed to delete {}", blob_path.display()))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
item.modified = now_unix();
|
||||||
|
vault.save_item(&item)?;
|
||||||
|
manifest.upsert(&item);
|
||||||
|
vault.save_manifest(&manifest)?;
|
||||||
|
|
||||||
|
let item_path = format!("items/{}.enc", item.id.as_str());
|
||||||
|
let blob_relpath = format!("attachments/{}/{}.enc", item.id.as_str(), removed.id.as_str());
|
||||||
|
commit_paths(
|
||||||
|
&vault,
|
||||||
|
&format!("detach: {} from {} ({})", removed.filename, item.title, item.id.as_str()),
|
||||||
|
&[&item_path, "manifest.enc", &blob_relpath],
|
||||||
|
)?;
|
||||||
|
eprintln!("Detached {} (aid={}) from {}", removed.filename, aid, item.title);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cmd_generate(
|
||||||
|
length: Option<u32>,
|
||||||
|
bip39: bool,
|
||||||
|
words: Option<u32>,
|
||||||
|
symbols: Option<String>,
|
||||||
|
separator: Option<String>,
|
||||||
|
) -> Result<()> {
|
||||||
use relicario_core::{
|
use relicario_core::{
|
||||||
generate_passphrase, generate_password, Capitalization, CharClasses,
|
generate_passphrase, generate_password, Capitalization, CharClasses,
|
||||||
GeneratorRequest, SymbolCharset,
|
GeneratorRequest, SymbolCharset,
|
||||||
};
|
};
|
||||||
|
|
||||||
let output = if bip39 {
|
// If we're inside a vault, unlock and pull `generator_defaults`. Outside
|
||||||
|
// a vault, this stays a fast standalone CSPRNG tool (no unlock prompt).
|
||||||
|
let vault_defaults: Option<GeneratorRequest> = if crate::helpers::vault_dir().is_ok() {
|
||||||
|
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||||
|
Some(vault.load_settings()?.generator_defaults)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
// `--bip39` flag forces Bip39 mode; otherwise use whatever mode the
|
||||||
|
// vault default is in (Random when no vault).
|
||||||
|
let use_bip39 = bip39 || matches!(vault_defaults, Some(GeneratorRequest::Bip39 { .. }));
|
||||||
|
|
||||||
|
let output = if use_bip39 {
|
||||||
|
let (def_words, def_sep, def_cap) = match &vault_defaults {
|
||||||
|
Some(GeneratorRequest::Bip39 { word_count, separator, capitalization }) => {
|
||||||
|
(*word_count, separator.clone(), *capitalization)
|
||||||
|
}
|
||||||
|
_ => (5, " ".to_string(), Capitalization::Lower),
|
||||||
|
};
|
||||||
generate_passphrase(&GeneratorRequest::Bip39 {
|
generate_passphrase(&GeneratorRequest::Bip39 {
|
||||||
word_count: words,
|
word_count: words.unwrap_or(def_words),
|
||||||
separator,
|
separator: separator.unwrap_or(def_sep),
|
||||||
capitalization: Capitalization::Lower,
|
capitalization: def_cap,
|
||||||
})?
|
})?
|
||||||
} else {
|
} else {
|
||||||
let symbol_charset = match symbols.as_str() {
|
let (def_length, def_classes, def_charset) = match &vault_defaults {
|
||||||
"safe" => SymbolCharset::SafeOnly,
|
Some(GeneratorRequest::Random { length, classes, symbol_charset }) => {
|
||||||
"extended" => SymbolCharset::Extended,
|
(*length, *classes, symbol_charset.clone())
|
||||||
other => SymbolCharset::Custom(other.to_string()),
|
}
|
||||||
|
_ => (
|
||||||
|
20,
|
||||||
|
CharClasses { lower: true, upper: true, digits: true, symbols: true },
|
||||||
|
SymbolCharset::SafeOnly,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
let symbol_charset = match symbols.as_deref() {
|
||||||
|
None => def_charset,
|
||||||
|
Some("safe") => SymbolCharset::SafeOnly,
|
||||||
|
Some("extended") => SymbolCharset::Extended,
|
||||||
|
Some(other) => SymbolCharset::Custom(other.to_string()),
|
||||||
};
|
};
|
||||||
generate_password(&GeneratorRequest::Random {
|
generate_password(&GeneratorRequest::Random {
|
||||||
length,
|
length: length.unwrap_or(def_length),
|
||||||
classes: CharClasses { lower: true, upper: true, digits: true, symbols: true },
|
classes: def_classes,
|
||||||
symbol_charset,
|
symbol_charset,
|
||||||
})?
|
})?
|
||||||
};
|
};
|
||||||
@@ -1222,7 +1488,10 @@ fn cmd_generate(length: u32, bip39: bool, words: u32, symbols: String, separator
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
fn cmd_settings(action: SettingsAction) -> Result<()> {
|
fn cmd_settings(action: SettingsAction) -> Result<()> {
|
||||||
use relicario_core::{HistoryRetention, TrashRetention};
|
use relicario_core::{
|
||||||
|
Capitalization, CharClasses, GeneratorRequest, HistoryRetention,
|
||||||
|
SymbolCharset, TrashRetention,
|
||||||
|
};
|
||||||
|
|
||||||
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||||
let mut settings = vault.load_settings()?;
|
let mut settings = vault.load_settings()?;
|
||||||
@@ -1256,6 +1525,53 @@ fn cmd_settings(action: SettingsAction) -> Result<()> {
|
|||||||
if let Some(v) = per_vault_soft_cap_bytes { settings.attachment_caps.per_vault_soft_cap_bytes = v; }
|
if let Some(v) = per_vault_soft_cap_bytes { settings.attachment_caps.per_vault_soft_cap_bytes = v; }
|
||||||
if let Some(v) = per_vault_hard_cap_bytes { settings.attachment_caps.per_vault_hard_cap_bytes = v; }
|
if let Some(v) = per_vault_hard_cap_bytes { settings.attachment_caps.per_vault_hard_cap_bytes = v; }
|
||||||
}
|
}
|
||||||
|
SettingsAction::GeneratorDefaults {
|
||||||
|
random, bip39, length, words, symbols, separator,
|
||||||
|
} => {
|
||||||
|
// Decide target mode: explicit flag wins, else preserve current.
|
||||||
|
let target_bip39 = if random { false }
|
||||||
|
else if bip39 { true }
|
||||||
|
else { matches!(settings.generator_defaults, GeneratorRequest::Bip39 { .. }) };
|
||||||
|
|
||||||
|
// Pull existing fields where compatible, else seed with sensible
|
||||||
|
// defaults (kept in sync with `GeneratorRequest::default()`).
|
||||||
|
let (cur_length, cur_classes, cur_charset) = match &settings.generator_defaults {
|
||||||
|
GeneratorRequest::Random { length, classes, symbol_charset } => {
|
||||||
|
(*length, *classes, symbol_charset.clone())
|
||||||
|
}
|
||||||
|
_ => (
|
||||||
|
20,
|
||||||
|
CharClasses { lower: true, upper: true, digits: true, symbols: true },
|
||||||
|
SymbolCharset::SafeOnly,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
let (cur_words, cur_sep, cur_cap) = match &settings.generator_defaults {
|
||||||
|
GeneratorRequest::Bip39 { word_count, separator, capitalization } => {
|
||||||
|
(*word_count, separator.clone(), *capitalization)
|
||||||
|
}
|
||||||
|
_ => (5, " ".to_string(), Capitalization::Lower),
|
||||||
|
};
|
||||||
|
|
||||||
|
settings.generator_defaults = if target_bip39 {
|
||||||
|
GeneratorRequest::Bip39 {
|
||||||
|
word_count: words.unwrap_or(cur_words),
|
||||||
|
separator: separator.unwrap_or(cur_sep),
|
||||||
|
capitalization: cur_cap,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let charset = match symbols.as_deref() {
|
||||||
|
None => cur_charset,
|
||||||
|
Some("safe") => SymbolCharset::SafeOnly,
|
||||||
|
Some("extended") => SymbolCharset::Extended,
|
||||||
|
Some(other) => SymbolCharset::Custom(other.to_string()),
|
||||||
|
};
|
||||||
|
GeneratorRequest::Random {
|
||||||
|
length: length.unwrap_or(cur_length),
|
||||||
|
classes: cur_classes,
|
||||||
|
symbol_charset: charset,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
vault.save_settings(&settings)?;
|
vault.save_settings(&settings)?;
|
||||||
@@ -1272,6 +1588,47 @@ fn cmd_sync() -> Result<()> {
|
|||||||
eprintln!("Sync complete.");
|
eprintln!("Sync complete.");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn cmd_status() -> Result<()> {
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||||
|
let root = vault.root().to_path_buf();
|
||||||
|
let manifest = vault.load_manifest()?;
|
||||||
|
|
||||||
|
let total_items = manifest.items.len();
|
||||||
|
let trashed_items = manifest.items.values().filter(|e| e.trashed_at.is_some()).count();
|
||||||
|
let active_items = total_items - trashed_items;
|
||||||
|
|
||||||
|
let (attachment_count, attachment_bytes) = manifest.items.values()
|
||||||
|
.flat_map(|e| e.attachment_summaries.iter())
|
||||||
|
.fold((0u64, 0u64), |(c, b), s| (c + 1, b + s.size));
|
||||||
|
|
||||||
|
// devices.json — count entries; missing/empty → 0.
|
||||||
|
let devices_path = root.join(".relicario").join("devices.json");
|
||||||
|
let device_count = match fs::read(&devices_path) {
|
||||||
|
Ok(bytes) => serde_json::from_slice::<serde_json::Value>(&bytes)
|
||||||
|
.ok()
|
||||||
|
.and_then(|v| v.as_array().map(|a| a.len()))
|
||||||
|
.unwrap_or(0),
|
||||||
|
Err(_) => 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
let last_commit = crate::helpers::git_command(&root, &[
|
||||||
|
"log", "-1", "--pretty=format:%h %s",
|
||||||
|
]).output()
|
||||||
|
.ok()
|
||||||
|
.and_then(|o| String::from_utf8(o.stdout).ok())
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.unwrap_or_else(|| "(no commits)".into());
|
||||||
|
|
||||||
|
println!("Vault: {}", root.display());
|
||||||
|
println!("Items: {total_items} total ({active_items} active, {trashed_items} trashed)");
|
||||||
|
println!("Attachments: {attachment_count} ({attachment_bytes} bytes)");
|
||||||
|
println!("Devices: {device_count}");
|
||||||
|
println!("Last commit: {last_commit}");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
fn cmd_device(action: DeviceAction) -> Result<()> {
|
fn cmd_device(action: DeviceAction) -> Result<()> {
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use ed25519_dalek::SigningKey;
|
use ed25519_dalek::SigningKey;
|
||||||
|
|||||||
@@ -29,6 +29,67 @@ fn attach_list_extract_round_trip() {
|
|||||||
assert_eq!(std::fs::read(out_path).unwrap(), b"attached-bytes");
|
assert_eq!(std::fs::read(out_path).unwrap(), b"attached-bytes");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detach_removes_attachment_and_blob() {
|
||||||
|
let v = TestVault::init();
|
||||||
|
v.run(&["add", "login", "--title", "thing",
|
||||||
|
"--username", "u", "--password", "p"]);
|
||||||
|
|
||||||
|
let payload_path = v.path().join("payload.txt");
|
||||||
|
std::fs::write(&payload_path, b"attached-bytes").unwrap();
|
||||||
|
let attach = v.run(&["attach", "thing", payload_path.to_str().unwrap()]);
|
||||||
|
assert!(attach.status.success());
|
||||||
|
|
||||||
|
let list = v.run(&["attachments", "thing"]);
|
||||||
|
let stdout = String::from_utf8(list.stdout).unwrap();
|
||||||
|
let aid = stdout.lines()
|
||||||
|
.find(|l| l.contains("payload.txt"))
|
||||||
|
.and_then(|l| l.split_whitespace().next())
|
||||||
|
.expect("aid token")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
// Detach removes the attachment from the item AND deletes the blob.
|
||||||
|
let out = v.run(&["detach", "thing", &aid]);
|
||||||
|
assert!(
|
||||||
|
out.status.success(),
|
||||||
|
"detach failed:\nstdout: {}\nstderr: {}",
|
||||||
|
String::from_utf8_lossy(&out.stdout),
|
||||||
|
String::from_utf8_lossy(&out.stderr),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Item no longer lists the attachment.
|
||||||
|
let list2 = v.run(&["attachments", "thing"]);
|
||||||
|
let stdout2 = String::from_utf8(list2.stdout).unwrap();
|
||||||
|
assert!(
|
||||||
|
!stdout2.contains("payload.txt"),
|
||||||
|
"attachment still listed after detach: {stdout2}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Encrypted blob file is gone.
|
||||||
|
let blob_path = v.path()
|
||||||
|
.join("attachments")
|
||||||
|
.join(stdout.lines().nth(1).is_some().then_some("").unwrap_or(""));
|
||||||
|
let item_attach_dir = std::fs::read_dir(v.path().join("attachments"))
|
||||||
|
.unwrap().next().unwrap().unwrap().path();
|
||||||
|
let blob = item_attach_dir.join(format!("{aid}.enc"));
|
||||||
|
assert!(!blob.exists(), "blob still on disk: {}", blob.display());
|
||||||
|
let _ = blob_path; // keep the variable to avoid an unused warning
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detach_refuses_unknown_aid() {
|
||||||
|
let v = TestVault::init();
|
||||||
|
v.run(&["add", "login", "--title", "thing",
|
||||||
|
"--username", "u", "--password", "p"]);
|
||||||
|
|
||||||
|
let out = v.run(&["detach", "thing", "deadbeef"]);
|
||||||
|
assert!(!out.status.success(), "expected failure: {:?}", out);
|
||||||
|
assert!(
|
||||||
|
String::from_utf8_lossy(&out.stderr).to_lowercase().contains("no attachment"),
|
||||||
|
"expected 'no attachment' error in stderr"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn attach_rejects_over_cap() {
|
fn attach_rejects_over_cap() {
|
||||||
let v = TestVault::init();
|
let v = TestVault::init();
|
||||||
|
|||||||
@@ -57,3 +57,135 @@ fn run_edit_with_pw_change(v: &TestVault, query: &str, new_pw: &str) -> std::pro
|
|||||||
}
|
}
|
||||||
child.wait_with_output().unwrap()
|
child.wait_with_output().unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn history_command_lists_per_field_entries() {
|
||||||
|
let v = TestVault::init();
|
||||||
|
v.run(&["add", "login", "--title", "bank",
|
||||||
|
"--username", "u", "--password", "first-pw"]);
|
||||||
|
let out = run_edit_with_pw_change(&v, "bank", "second-pw");
|
||||||
|
assert!(out.status.success(), "edit failed: {:?}", out);
|
||||||
|
|
||||||
|
// `history <query>` should list the captured field and a count.
|
||||||
|
let out = v.run(&["history", "bank"]);
|
||||||
|
assert!(
|
||||||
|
out.status.success(),
|
||||||
|
"history failed:\nstdout: {}\nstderr: {}",
|
||||||
|
String::from_utf8_lossy(&out.stdout),
|
||||||
|
String::from_utf8_lossy(&out.stderr),
|
||||||
|
);
|
||||||
|
let stdout = String::from_utf8(out.stdout).unwrap();
|
||||||
|
assert!(
|
||||||
|
stdout.contains("login_password"),
|
||||||
|
"expected login_password key, got: {stdout}"
|
||||||
|
);
|
||||||
|
// Default (no --show) hides values.
|
||||||
|
assert!(
|
||||||
|
!stdout.contains("first-pw"),
|
||||||
|
"values should be masked without --show: {stdout}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
stdout.contains("****"),
|
||||||
|
"expected masked value indicator: {stdout}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn history_command_show_reveals_prior_values() {
|
||||||
|
let v = TestVault::init();
|
||||||
|
v.run(&["add", "login", "--title", "bank",
|
||||||
|
"--username", "u", "--password", "first-pw"]);
|
||||||
|
let out = run_edit_with_pw_change(&v, "bank", "second-pw");
|
||||||
|
assert!(out.status.success());
|
||||||
|
|
||||||
|
let out = v.run(&["history", "bank", "--show"]);
|
||||||
|
assert!(out.status.success(), "history --show failed: {:?}", out);
|
||||||
|
let stdout = String::from_utf8(out.stdout).unwrap();
|
||||||
|
assert!(
|
||||||
|
stdout.contains("first-pw"),
|
||||||
|
"expected old value 'first-pw' in --show output: {stdout}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn history_command_reports_empty_when_nothing_changed() {
|
||||||
|
let v = TestVault::init();
|
||||||
|
v.run(&["add", "login", "--title", "untouched",
|
||||||
|
"--username", "u", "--password", "pw"]);
|
||||||
|
|
||||||
|
let out = v.run(&["history", "untouched"]);
|
||||||
|
assert!(out.status.success(), "history failed: {:?}", out);
|
||||||
|
let stdout = String::from_utf8(out.stdout).unwrap();
|
||||||
|
assert!(
|
||||||
|
stdout.to_lowercase().contains("no history"),
|
||||||
|
"expected 'no history' message, got: {stdout}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn edit_totp_rotates_secret_and_captures_history() {
|
||||||
|
let v = TestVault::init();
|
||||||
|
v.run(&[
|
||||||
|
"add", "totp",
|
||||||
|
"--title", "github",
|
||||||
|
"--issuer", "github.com",
|
||||||
|
"--label", "alice",
|
||||||
|
"--secret", "JBSWY3DPEHPK3PXP",
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Edit: change issuer, label, then rotate the secret to a new base32 value.
|
||||||
|
let out = run_edit_totp(&v, "github", "github-new.com", "alice@new", "NB2W45DFOIZA");
|
||||||
|
assert!(
|
||||||
|
out.status.success(),
|
||||||
|
"edit failed:\nstdout: {}\nstderr: {}",
|
||||||
|
String::from_utf8_lossy(&out.stdout),
|
||||||
|
String::from_utf8_lossy(&out.stderr)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify the issuer and label changes persisted by reading the item back.
|
||||||
|
let out = v.run(&["get", "github"]);
|
||||||
|
assert!(out.status.success(), "get failed: {:?}", out);
|
||||||
|
let stdout = String::from_utf8(out.stdout).unwrap();
|
||||||
|
assert!(
|
||||||
|
stdout.contains("github-new.com"),
|
||||||
|
"expected new issuer in get output, got: {stdout}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
stdout.contains("alice@new"),
|
||||||
|
"expected new label in get output, got: {stdout}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drives the interactive `edit` flow for a TOTP item with secret rotation.
|
||||||
|
/// Stdin order: Title, Group, Tags (all blank to keep), Issuer, Label,
|
||||||
|
/// then "y" to "Change TOTP secret?" The new secret comes from
|
||||||
|
/// RELICARIO_TEST_ITEM_SECRET.
|
||||||
|
fn run_edit_totp(
|
||||||
|
v: &TestVault,
|
||||||
|
query: &str,
|
||||||
|
new_issuer: &str,
|
||||||
|
new_label: &str,
|
||||||
|
new_secret_b32: &str,
|
||||||
|
) -> std::process::Output {
|
||||||
|
use assert_cmd::cargo::CommandCargoExt;
|
||||||
|
use std::io::Write;
|
||||||
|
use std::process::{Command, Stdio};
|
||||||
|
|
||||||
|
let mut cmd = Command::cargo_bin("relicario").unwrap();
|
||||||
|
cmd.current_dir(v.path())
|
||||||
|
.env("RELICARIO_IMAGE", &v.reference_image)
|
||||||
|
.env("RELICARIO_TEST_PASSPHRASE", &v.passphrase)
|
||||||
|
.env("RELICARIO_TEST_ITEM_SECRET", new_secret_b32)
|
||||||
|
.args(["edit", query])
|
||||||
|
.stdin(Stdio::piped())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped());
|
||||||
|
let mut child = cmd.spawn().unwrap();
|
||||||
|
{
|
||||||
|
let stdin = child.stdin.as_mut().unwrap();
|
||||||
|
for line in ["", "", "", new_issuer, new_label, "y"] {
|
||||||
|
writeln!(stdin, "{line}").unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
child.wait_with_output().unwrap()
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,3 +21,115 @@ fn settings_rejects_conflicting_retention_flags() {
|
|||||||
let out = v.run(&["settings", "trash-retention", "--days", "30", "--forever"]);
|
let out = v.run(&["settings", "trash-retention", "--days", "30", "--forever"]);
|
||||||
assert!(!out.status.success());
|
assert!(!out.status.success());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generate_uses_vault_default_length() {
|
||||||
|
let v = TestVault::init();
|
||||||
|
|
||||||
|
// Default vault settings: GeneratorRequest::Random { length: 20, ... }.
|
||||||
|
let out = v.run(&["generate"]);
|
||||||
|
assert!(out.status.success(), "{:?}", String::from_utf8_lossy(&out.stderr));
|
||||||
|
let pw = String::from_utf8(out.stdout).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
pw.trim().chars().count(),
|
||||||
|
20,
|
||||||
|
"expected 20 chars at default, got {pw:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update the vault default length to 32.
|
||||||
|
let out = v.run(&["settings", "generator-defaults", "--length", "32"]);
|
||||||
|
assert!(
|
||||||
|
out.status.success(),
|
||||||
|
"set generator-defaults failed: {}",
|
||||||
|
String::from_utf8_lossy(&out.stderr)
|
||||||
|
);
|
||||||
|
|
||||||
|
// `generate` (no flags) should now produce 32 chars.
|
||||||
|
let out = v.run(&["generate"]);
|
||||||
|
assert!(out.status.success(), "{:?}", String::from_utf8_lossy(&out.stderr));
|
||||||
|
let pw = String::from_utf8(out.stdout).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
pw.trim().chars().count(),
|
||||||
|
32,
|
||||||
|
"expected 32 chars after update, got {pw:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Explicit flag overrides the vault default.
|
||||||
|
let out = v.run(&["generate", "--length", "8"]);
|
||||||
|
assert!(out.status.success());
|
||||||
|
let pw = String::from_utf8(out.stdout).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
pw.trim().chars().count(),
|
||||||
|
8,
|
||||||
|
"explicit flag should override vault default, got {pw:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn status_reports_item_attachment_and_device_counts() {
|
||||||
|
let v = TestVault::init();
|
||||||
|
v.run(&["add", "login", "--title", "active",
|
||||||
|
"--username", "u", "--password", "p"]);
|
||||||
|
v.run(&["add", "login", "--title", "to-trash",
|
||||||
|
"--username", "u", "--password", "p"]);
|
||||||
|
v.run(&["rm", "to-trash"]);
|
||||||
|
|
||||||
|
let payload = v.path().join("payload.txt");
|
||||||
|
std::fs::write(&payload, b"hello-world").unwrap();
|
||||||
|
v.run(&["attach", "active", payload.to_str().unwrap()]);
|
||||||
|
|
||||||
|
let out = v.run(&["status"]);
|
||||||
|
assert!(
|
||||||
|
out.status.success(),
|
||||||
|
"status failed:\nstdout: {}\nstderr: {}",
|
||||||
|
String::from_utf8_lossy(&out.stdout),
|
||||||
|
String::from_utf8_lossy(&out.stderr),
|
||||||
|
);
|
||||||
|
let stdout = String::from_utf8(out.stdout).unwrap();
|
||||||
|
let lower = stdout.to_lowercase();
|
||||||
|
|
||||||
|
// 1 active + 1 trashed = 2 items total.
|
||||||
|
assert!(lower.contains("items"), "missing items section: {stdout}");
|
||||||
|
assert!(stdout.contains('2') || stdout.contains("2 ")
|
||||||
|
|| lower.contains("active: 1") || lower.contains("1 active"),
|
||||||
|
"expected item counts in output: {stdout}");
|
||||||
|
assert!(lower.contains("trash"), "missing trash count: {stdout}");
|
||||||
|
|
||||||
|
// 1 attachment, 11 bytes.
|
||||||
|
assert!(lower.contains("attachment"), "missing attachment section: {stdout}");
|
||||||
|
assert!(stdout.contains("11"), "expected 11-byte size in output: {stdout}");
|
||||||
|
|
||||||
|
// 0 devices in default test vault (init does not register one).
|
||||||
|
assert!(lower.contains("device"), "missing devices section: {stdout}");
|
||||||
|
|
||||||
|
// Last-commit line.
|
||||||
|
assert!(
|
||||||
|
lower.contains("last commit") || lower.contains("commit"),
|
||||||
|
"missing last-commit info: {stdout}",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generate_works_outside_vault() {
|
||||||
|
use assert_cmd::cargo::CommandCargoExt;
|
||||||
|
use std::process::{Command, Stdio};
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let out = Command::cargo_bin("relicario")
|
||||||
|
.unwrap()
|
||||||
|
.current_dir(tmp.path())
|
||||||
|
.args(["generate", "--length", "12"])
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
out.status.success(),
|
||||||
|
"no-vault generate failed: {}",
|
||||||
|
String::from_utf8_lossy(&out.stderr)
|
||||||
|
);
|
||||||
|
let pw = String::from_utf8(out.stdout).unwrap();
|
||||||
|
assert_eq!(pw.trim().chars().count(), 12);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user