merge(cycle-1): land Stream B — Plan B Phases 1+2 (main.rs split + git_run)

18 commits from feature/arch-followup-stream-b-cli-restructure landing the
mechanical split of crates/relicario-cli/src/main.rs into commands/ +
parse.rs + prompt.rs (Plan B Phase 1) plus the git_run helper and 15-site
sweep (Plan B Phase 2). main.rs lands at 509 LOC: substance criterion
met (clap surface + dispatch + 2 shim families only); the 9-LOC overshoot
vs spec's =500 is #[arg(...)] attribute density on 9 sub-enums and was
accepted at cycle-1 review.

Phase 1 (split):
- 02e05f7 refactor(cli): add commands/, prompt.rs, parse.rs scaffold (no-op)
- 272b6a3 refactor(cli): move prompt helpers into prompt.rs
- 5240023 refactor(cli): move parse helpers into parse.rs
- 17bde16 refactor(cli): move cmd_generate + cmd_rate into commands/
- b9b07ec refactor(cli): move cmd_init into commands/init.rs (carries inline ParamsFile)
- 13c2fc2 refactor(cli): hoist commit_paths + resolve_query into commands/mod.rs
- da7d7d1 refactor(cli): move cmd_get/list/history/status/sync into commands/
- 530c479 refactor(cli): move trash family (rm/restore/purge/trash) into commands/
- c2f3c35 refactor(cli): move cmd_backup into commands/backup.rs
- 615afd7 refactor(cli): move cmd_import into commands/import.rs
- 6676d25 refactor(cli): move attach family (attach/attachments/extract/detach)
- 3811b07 refactor(cli): move cmd_recovery_qr family into commands/recovery_qr.rs
- 08bdfbc refactor(cli): move cmd_settings into commands/settings.rs
- 2d5b86b refactor(cli): move cmd_device + load_gitea_client into commands/device.rs
- 64275bc refactor(cli): move cmd_edit family into commands/edit.rs
- 2d1f092 refactor(cli): move cmd_add + 7 build_*_item helpers into commands/add.rs

Phase 2 (git_run):
- f3cdbed feat(cli): add helpers::git_run with stderr capture + context bail
- 97c8f99 refactor(cli): sweep 15 bail("git X") sites to use git_run with context labels

Pre-merge checklist on tip 97c8f99:
- cargo test --workspace: all green (helpers, attachments, basic_flows, backup,
  edit_and_history, import, settings, vault_detection)
- cargo clippy --workspace --all-targets: silent
- cargo build -p relicario-wasm --target wasm32-unknown-unknown: clean

Plan B Phases 1+2 complete. Phases 3-8 partitioned across cycle-2 streams.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
adlee-was-taken
2026-05-09 10:37:59 -04:00
21 changed files with 2367 additions and 2171 deletions

View File

@@ -0,0 +1,314 @@
//! `relicario add <kind>` — create a new item of the given type.
//!
//! `cmd_add` does the common save / manifest upsert / commit dance. The seven
//! per-type `build_*_item` helpers each return a fully-populated `Item`. The
//! `Document` builder is the only one that needs the unlocked vault (for the
//! attachment-cap settings + writing the encrypted blob alongside the item).
use std::path::PathBuf;
use anyhow::{Context, Result};
use crate::AddKind;
use crate::parse::{base32_decode_lenient, guess_mime, parse_month_year};
use crate::prompt::{prompt, prompt_optional, prompt_secret};
pub fn cmd_add(kind: AddKind) -> Result<()> {
let vault = crate::session::UnlockedVault::unlock_interactive()?;
let mut manifest = vault.load_manifest()?;
let item = match kind {
AddKind::Login { title, username, url, password_prompt, password, group, tags, favorite, totp_qr } =>
build_login_item(title, username, url, password_prompt, password, group, tags, favorite, totp_qr)?,
AddKind::SecureNote { title, body_prompt, group, tags } =>
build_secure_note_item(title, body_prompt, group, tags)?,
AddKind::Identity { title, full_name, email, phone, date_of_birth, group, tags } =>
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)?;
crate::refresh_groups_cache(vault.root(), &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();
super::commit_paths(&vault, &format!("add: {} ({})", crate::helpers::sanitize_for_commit(&item.title), item.id.as_str()), &path_refs)?;
eprintln!("Added: {} (id={})", item.title, item.id.as_str());
Ok(())
}
#[allow(clippy::too_many_arguments)]
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,
totp_qr: Option<PathBuf>,
) -> Result<relicario_core::Item> {
use relicario_core::item_types::{LoginCore, TotpAlgorithm, TotpConfig, TotpKind};
use relicario_core::{Item, ItemCore};
use zeroize::Zeroizing;
let title = title.map(Ok).unwrap_or_else(|| prompt("Title"))?;
let username = username.or_else(|| prompt_optional("Username").ok().flatten());
let url = url.or_else(|| prompt_optional("URL").ok().flatten());
let parsed_url = match url {
Some(s) => Some(url::Url::parse(&s).with_context(|| format!("invalid URL: {s}"))?),
None => None,
};
let password = if let Some(p) = password {
Some(Zeroizing::new(p))
} else if password_prompt {
Some(Zeroizing::new(prompt_secret("Password: ")?))
} else {
None
};
let totp = if let Some(path) = totp_qr {
let secret_b32 = crate::helpers::decode_totp_qr(&path)?;
let secret_bytes = base32_decode_lenient(&secret_b32)?;
Some(TotpConfig {
secret: Zeroizing::new(secret_bytes),
algorithm: TotpAlgorithm::Sha1,
digits: 6,
period_seconds: 30,
kind: TotpKind::Totp,
})
} else {
None
};
let mut item = Item::new(title, ItemCore::Login(LoginCore {
username, password, url: parsed_url, totp,
}));
item.group = group;
item.tags = tags;
item.favorite = favorite;
Ok(item)
}
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, ItemCore};
use zeroize::Zeroizing;
let title = title.map(Ok).unwrap_or_else(|| prompt("Title"))?;
let body = if body_prompt {
eprintln!("Enter note body; end with Ctrl-D on a blank line:");
let mut s = String::new();
std::io::Read::read_to_string(&mut std::io::stdin(), &mut s)?;
s
} else {
prompt("Body")?
};
let mut item = Item::new(title, ItemCore::SecureNote(SecureNoteCore {
body: Zeroizing::new(body),
}));
item.group = group;
item.tags = tags;
Ok(item)
}
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, ItemCore};
let title = title.map(Ok).unwrap_or_else(|| prompt("Title"))?;
let dob = match date_of_birth {
Some(s) => Some(chrono::NaiveDate::parse_from_str(&s, "%Y-%m-%d")
.with_context(|| format!("invalid date {s} (expected YYYY-MM-DD)"))?),
None => None,
};
let mut item = Item::new(title, ItemCore::Identity(IdentityCore {
full_name, address: None, phone, email, date_of_birth: dob,
}));
item.group = group;
item.tags = tags;
Ok(item)
}
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, ItemCore};
use zeroize::Zeroizing;
let title = title.map(Ok).unwrap_or_else(|| prompt("Title"))?;
let number = Zeroizing::new(prompt_secret("Card number: ")?);
let cvv = Zeroizing::new(prompt_secret("CVV (blank to skip): ")?);
let cvv = if cvv.is_empty() { None } else { Some(cvv) };
let pin = Zeroizing::new(prompt_secret("PIN (blank to skip): ")?);
let pin = if pin.is_empty() { None } else { Some(pin) };
let parsed_expiry = match expiry {
Some(s) => Some(parse_month_year(&s)?),
None => None,
};
let parsed_kind = match kind.as_str() {
"credit" => CardKind::Credit,
"debit" => CardKind::Debit,
"gift" => CardKind::Gift,
"loyalty" => CardKind::Loyalty,
"other" => CardKind::Other,
other => anyhow::bail!("unknown card kind: {other}"),
};
let mut item = Item::new(title, ItemCore::Card(CardCore {
number: Some(number), holder, expiry: parsed_expiry, cvv, pin, kind: parsed_kind,
}));
item.group = group;
item.tags = tags;
Ok(item)
}
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, ItemCore};
use zeroize::Zeroizing;
let title = title.map(Ok).unwrap_or_else(|| prompt("Title"))?;
eprintln!("Paste key material; end with Ctrl-D on a blank line:");
let mut key_material = String::new();
std::io::Read::read_to_string(&mut std::io::stdin(), &mut key_material)?;
if key_material.trim().is_empty() { anyhow::bail!("key material required"); }
let public_key = prompt_optional("Public key (blank to skip)")?;
let mut item = Item::new(title, ItemCore::Key(KeyCore {
key_material: Zeroizing::new(key_material),
label, public_key, algorithm,
}));
item.group = group;
item.tags = tags;
Ok(item)
}
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::{encrypt_attachment, AttachmentRef, Item, ItemCore};
use std::fs;
let title = title.map(Ok).unwrap_or_else(|| prompt("Title"))?;
let bytes = fs::read(&file)
.with_context(|| format!("failed to read {}", file.display()))?;
let caps = vault.load_settings()?.attachment_caps;
let enc = encrypt_attachment(&bytes, vault.key(), caps.per_attachment_max_bytes)?;
let filename = file.file_name()
.ok_or_else(|| anyhow::anyhow!("file path has no filename: {}", file.display()))?
.to_string_lossy()
.into_owned();
let mime_type = guess_mime(&filename);
let primary_attachment = enc.id.clone();
let mut item = Item::new(title, ItemCore::Document(DocumentCore {
filename: filename.clone(),
mime_type: mime_type.clone(),
primary_attachment: primary_attachment.clone(),
}));
item.group = group;
item.tags = tags;
item.attachments.push(AttachmentRef {
id: primary_attachment.clone(),
filename, mime_type,
size: bytes.len() as u64,
created: item.created,
});
let att_dir = vault.root().join("attachments").join(item.id.as_str());
fs::create_dir_all(&att_dir)?;
fs::write(att_dir.join(format!("{}.enc", primary_attachment.as_str())), &enc.bytes)?;
Ok(item)
}
#[allow(clippy::too_many_arguments)]
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, ItemCore};
use zeroize::Zeroizing;
let title = title.map(Ok).unwrap_or_else(|| prompt("Title"))?;
let secret_b32 = match secret {
Some(s) => s,
None => prompt_secret("TOTP secret (base32): ")?,
};
let secret_bytes = base32_decode_lenient(&secret_b32)?;
let algo = match algorithm.to_ascii_lowercase().as_str() {
"sha1" => TotpAlgorithm::Sha1,
"sha256" => TotpAlgorithm::Sha256,
"sha512" => TotpAlgorithm::Sha512,
other => anyhow::bail!("unknown algorithm: {other}"),
};
let mut item = Item::new(title, ItemCore::Totp(TotpCore {
config: TotpConfig {
secret: Zeroizing::new(secret_bytes),
algorithm: algo,
digits,
period_seconds: period,
kind: TotpKind::Totp,
},
issuer, label,
}));
item.group = group;
item.tags = tags;
Ok(item)
}

View File

@@ -0,0 +1,175 @@
//! `relicario attach` / `attachments` / `extract` / `detach` — per-attachment ops.
use std::path::PathBuf;
use anyhow::{Context, Result};
use crate::parse::guess_mime;
pub fn cmd_attach(query: String, file: PathBuf) -> Result<()> {
use std::fs;
use relicario_core::{encrypt_attachment, AttachmentRef};
use relicario_core::time::now_unix;
let vault = crate::session::UnlockedVault::unlock_interactive()?;
let mut manifest = vault.load_manifest()?;
let entry = super::resolve_query(&manifest, &query)?;
let id = entry.id.clone();
let _ = entry;
let mut item = vault.load_item(&id)?;
let settings = vault.load_settings()?;
let caps = settings.attachment_caps;
if item.attachments.len() as u32 >= caps.per_item_max_count {
anyhow::bail!("item already has {} attachments (max {})",
item.attachments.len(), caps.per_item_max_count);
}
let bytes = fs::read(&file)
.with_context(|| format!("failed to read {}", file.display()))?;
// Check per-vault total attachment bytes cap (audit I3).
let current_total: u64 = manifest.items.values()
.flat_map(|e| &e.attachment_summaries)
.map(|s| s.size)
.sum();
let new_size = bytes.len() as u64;
let hard_cap = caps.per_vault_hard_cap_bytes;
let soft_cap = caps.per_vault_soft_cap_bytes;
if current_total + new_size > hard_cap {
anyhow::bail!(
"attachment would exceed vault hard cap ({} + {} > {} bytes)",
current_total, new_size, hard_cap
);
}
if current_total + new_size > soft_cap {
eprintln!(
"warning: vault attachments will exceed soft cap ({} bytes)",
soft_cap
);
}
let enc = encrypt_attachment(&bytes, vault.key(), caps.per_attachment_max_bytes)?;
let filename = file.file_name()
.ok_or_else(|| anyhow::anyhow!("file path has no filename: {}", file.display()))?
.to_string_lossy()
.into_owned();
let mime_type = guess_mime(&filename);
let aref = AttachmentRef {
id: enc.id.clone(),
filename,
mime_type,
size: bytes.len() as u64,
created: now_unix(),
};
let att_dir = vault.root().join("attachments").join(item.id.as_str());
fs::create_dir_all(&att_dir)?;
fs::write(att_dir.join(format!("{}.enc", enc.id.as_str())), &enc.bytes)?;
item.attachments.push(aref);
item.modified = now_unix();
vault.save_item(&item)?;
manifest.upsert(&item);
vault.save_manifest(&manifest)?;
let paths = [
format!("items/{}.enc", item.id.as_str()),
"manifest.enc".into(),
format!("attachments/{}/{}.enc", item.id.as_str(), enc.id.as_str()),
];
let path_refs: Vec<&str> = paths.iter().map(|s| s.as_str()).collect();
super::commit_paths(&vault, &format!("attach: {}{} ({})",
crate::helpers::sanitize_for_commit(&file.display().to_string()),
crate::helpers::sanitize_for_commit(&item.title),
item.id.as_str()), &path_refs)?;
eprintln!("Attached {} to {} (aid={})", file.display(), item.title, enc.id.as_str());
Ok(())
}
pub fn cmd_attachments(query: String) -> Result<()> {
let vault = crate::session::UnlockedVault::unlock_interactive()?;
let manifest = vault.load_manifest()?;
let entry = super::resolve_query(&manifest, &query)?;
let item = vault.load_item(&entry.id)?;
if item.attachments.is_empty() { eprintln!("(no attachments)"); return Ok(()); }
println!("{:<17} {:>12} {:<22} FILENAME", "AID", "SIZE", "MIME");
for a in &item.attachments {
println!("{:<17} {:>12} {:<22} {}", a.id.as_str(), a.size, a.mime_type, a.filename);
}
Ok(())
}
pub fn cmd_extract(query: String, aid: String, out: Option<PathBuf>) -> Result<()> {
use std::fs;
use relicario_core::decrypt_attachment;
let vault = crate::session::UnlockedVault::unlock_interactive()?;
let manifest = vault.load_manifest()?;
let entry = super::resolve_query(&manifest, &query)?;
let item = vault.load_item(&entry.id)?;
let aref = item.attachments.iter().find(|a| a.id.as_str() == aid)
.ok_or_else(|| anyhow::anyhow!("no attachment {aid} on {}", item.title))?;
let path = vault.root().join("attachments").join(item.id.as_str())
.join(format!("{}.enc", aid));
let bytes = fs::read(&path)
.with_context(|| format!("failed to read {}", path.display()))?;
let plaintext = decrypt_attachment(&bytes, vault.key())?;
let out_path = out.unwrap_or_else(|| PathBuf::from(&aref.filename));
fs::write(&out_path, plaintext.as_slice())
.with_context(|| format!("failed to write {}", out_path.display()))?;
eprintln!("Wrote {} bytes to {}", plaintext.len(), out_path.display());
Ok(())
}
pub 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 = super::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());
super::commit_paths(
&vault,
&format!("detach: {} from {} ({})", crate::helpers::sanitize_for_commit(&removed.filename), crate::helpers::sanitize_for_commit(&item.title), item.id.as_str()),
&[&item_path, "manifest.enc", &blob_relpath],
)?;
eprintln!("Detached {} (aid={}) from {}", removed.filename, aid, item.title);
Ok(())
}

View File

@@ -0,0 +1,303 @@
//! `relicario backup export` / `relicario backup restore` — pack/unpack the
//! encrypted `.relbak` envelope.
use std::path::PathBuf;
use anyhow::{Context, Result};
use crate::BackupAction;
pub fn cmd_backup(action: BackupAction) -> Result<()> {
match action {
BackupAction::Export { out, include_image, image, no_history } => {
cmd_backup_export(out, include_image, image, no_history)
}
BackupAction::Restore { input, target } => cmd_backup_restore(input, target),
}
}
pub(super) fn cmd_backup_export(
out: PathBuf,
include_image: bool,
image: Option<PathBuf>,
no_history: bool,
) -> Result<()> {
use std::fs;
use relicario_core::{backup, validate_passphrase_strength};
use zeroize::Zeroizing;
let root = crate::helpers::vault_dir()?;
// Backup passphrase — prompt twice, gate on zxcvbn (audit H3).
let passphrase = if let Some(p) = crate::test_backup_passphrase_override() {
Zeroizing::new(p)
} else {
Zeroizing::new(rpassword::prompt_password("Backup passphrase: ")?)
};
let confirm = if crate::test_backup_passphrase_override().is_some() {
passphrase.clone()
} else {
Zeroizing::new(rpassword::prompt_password("Confirm passphrase: ")?)
};
if passphrase.as_str() != confirm.as_str() {
anyhow::bail!("passphrases do not match");
}
if let Err(e) = validate_passphrase_strength(&passphrase) {
anyhow::bail!("backup {}. Choose a longer or more entropic phrase.", e);
}
// Read everything from disk that the envelope needs.
let salt = fs::read(root.join(".relicario").join("salt"))
.with_context(|| "failed to read .relicario/salt")?;
let params_json = fs::read_to_string(root.join(".relicario").join("params.json"))
.with_context(|| "failed to read .relicario/params.json")?;
// devices.json was removed in the B1 security audit fix; fall back to
// an empty array so backups of post-B1 vaults still pack cleanly.
// Task 12 will remove the devices field from the backup format entirely.
let devices_json = fs::read_to_string(root.join(".relicario").join("devices.json"))
.unwrap_or_else(|_| "[]".to_string());
let manifest_enc = fs::read(root.join("manifest.enc"))
.with_context(|| "failed to read manifest.enc")?;
let settings_enc = fs::read(root.join("settings.enc"))
.with_context(|| "failed to read settings.enc")?;
// Items.
let mut item_files = Vec::new();
let items_dir = root.join("items");
if items_dir.is_dir() {
for entry in fs::read_dir(&items_dir)? {
let p = entry?.path();
if p.extension().and_then(|s| s.to_str()) != Some("enc") { continue; }
let id = p.file_stem()
.and_then(|s| s.to_str())
.ok_or_else(|| anyhow::anyhow!("bad item filename: {}", p.display()))?
.to_string();
let bytes = fs::read(&p)?;
item_files.push((id, bytes));
}
}
// Attachments. Layout: attachments/<item_id>/<aid>.enc
let mut attach_files = Vec::new();
let attach_dir = root.join("attachments");
if attach_dir.is_dir() {
for entry in fs::read_dir(&attach_dir)? {
let item_dir = entry?.path();
if !item_dir.is_dir() { continue; }
let item_id = item_dir.file_name()
.and_then(|s| s.to_str())
.ok_or_else(|| anyhow::anyhow!("bad attachment dir: {}", item_dir.display()))?
.to_string();
for sub in fs::read_dir(&item_dir)? {
let p = sub?.path();
if p.extension().and_then(|s| s.to_str()) != Some("enc") { continue; }
let aid = p.file_stem()
.and_then(|s| s.to_str())
.ok_or_else(|| anyhow::anyhow!("bad attachment filename: {}", p.display()))?
.to_string();
let bytes = fs::read(&p)?;
attach_files.push((item_id.clone(), aid, bytes));
}
}
}
// Optional reference image.
let image_bytes = if include_image {
let path = match image {
Some(p) => p,
None => crate::session::get_image_path()?,
};
Some(fs::read(&path)
.with_context(|| format!("failed to read reference image {}", path.display()))?)
} else {
None
};
// Optional .git/ tar.
let git_archive = if no_history { None } else { Some(tar_directory(&root.join(".git"))?) };
let items_refs: Vec<backup::BackupItem> = item_files.iter()
.map(|(id, bytes)| backup::BackupItem { id: id.clone(), ciphertext: bytes })
.collect();
let attach_refs: Vec<backup::BackupAttachment> = attach_files.iter()
.map(|(iid, aid, bytes)| backup::BackupAttachment {
item_id: iid.clone(),
attachment_id: aid.clone(),
ciphertext: bytes,
})
.collect();
let input = backup::BackupInput {
salt: &salt,
params_json: &params_json,
devices_json: &devices_json,
manifest_enc: &manifest_enc,
settings_enc: &settings_enc,
items: items_refs,
attachments: attach_refs,
reference_jpg: image_bytes.as_deref(),
git_archive: git_archive.as_deref(),
};
let bytes = backup::pack_backup(input, &passphrase)?;
// atomic_write via the existing pattern: write `.tmp`, rename.
let tmp = {
let mut t = out.as_os_str().to_owned();
t.push(".tmp");
PathBuf::from(t)
};
fs::write(&tmp, &bytes)
.with_context(|| format!("failed to write {}", tmp.display()))?;
fs::rename(&tmp, &out)
.with_context(|| format!("failed to rename {}", out.display()))?;
// Marker file for `cmd_status`. Format: ISO-8601 UTC line.
let now_iso = crate::helpers::iso8601(relicario_core::now_unix());
fs::write(root.join(".relicario").join("last_backup"), format!("{now_iso}\n"))?;
let mib = (bytes.len() as f64) / (1024.0 * 1024.0);
eprintln!(
"Wrote {} ({:.2} MiB). Delete after restore is verified.",
out.display(), mib
);
Ok(())
}
/// Tar a directory into an in-memory `Vec<u8>`. Used for `.git/` bundling.
fn tar_directory(dir: &std::path::Path) -> Result<Vec<u8>> {
let mut buf = Vec::new();
{
let mut builder = tar::Builder::new(&mut buf);
builder.append_dir_all(".", dir)
.with_context(|| format!("failed to tar {}", dir.display()))?;
builder.finish().with_context(|| "failed to finalize git tar")?;
}
Ok(buf)
}
pub(super) fn cmd_backup_restore(input: PathBuf, target: PathBuf) -> Result<()> {
use std::fs;
use relicario_core::backup;
use relicario_core::{ItemId, AttachmentId};
use zeroize::Zeroizing;
let target = if target.is_absolute() {
target
} else {
std::env::current_dir()?.join(&target)
};
if target.join(".relicario").exists() {
anyhow::bail!(
"target dir already contains a Relicario vault; restore refuses to overwrite — use an empty directory: {}",
target.display()
);
}
fs::create_dir_all(&target)
.with_context(|| format!("failed to create target {}", target.display()))?;
// Read input file.
let bytes = fs::read(&input)
.with_context(|| format!("failed to read backup file {}", input.display()))?;
// Backup passphrase prompt.
let passphrase = if let Some(p) = crate::test_backup_passphrase_override() {
Zeroizing::new(p)
} else {
Zeroizing::new(rpassword::prompt_password("Backup passphrase: ")?)
};
let unpacked = backup::unpack_backup(&bytes, &passphrase)
.map_err(|e| match e {
relicario_core::RelicarioError::Decrypt =>
anyhow::anyhow!("wrong backup passphrase, or the file is corrupt"),
other => anyhow::anyhow!(other),
})?;
// Write vault layout.
let relicario_dir = target.join(".relicario");
fs::create_dir_all(&relicario_dir)?;
fs::create_dir_all(target.join("items"))?;
fs::create_dir_all(target.join("attachments"))?;
fs::write(relicario_dir.join("salt"), unpacked.salt)?;
fs::write(relicario_dir.join("params.json"), &unpacked.params_json)?;
fs::write(relicario_dir.join("devices.json"), &unpacked.devices_json)?;
fs::write(target.join("manifest.enc"), &unpacked.manifest_enc)?;
fs::write(target.join("settings.enc"), &unpacked.settings_enc)?;
for item in &unpacked.items {
let item_id = ItemId(item.id.clone());
if !item_id.is_valid() {
anyhow::bail!("invalid item ID in backup: {} (path traversal blocked)", item.id);
}
fs::write(target.join("items").join(format!("{}.enc", item.id)), &item.ciphertext)?;
}
for a in &unpacked.attachments {
let item_id = ItemId(a.item_id.clone());
let att_id = AttachmentId(a.attachment_id.clone());
if !item_id.is_valid() || !att_id.is_valid() {
anyhow::bail!("invalid attachment ID in backup (path traversal blocked)");
}
let dir = target.join("attachments").join(&a.item_id);
fs::create_dir_all(&dir)?;
fs::write(dir.join(format!("{}.enc", a.attachment_id)), &a.ciphertext)?;
}
// Reference image (if present).
if let Some(jpg) = &unpacked.reference_jpg {
let path = target.join("reference.jpg");
fs::write(&path, jpg)
.with_context(|| format!("failed to write reference image {}", path.display()))?;
}
// .git/ history.
if let Some(tar_bytes) = &unpacked.git_archive {
// Cap: 100× the compressed bundle size, or 1 GiB, whichever is lower.
let cap = std::cmp::min(
(tar_bytes.len() as u64).saturating_mul(100),
relicario_core::DEFAULT_MAX_UNCOMPRESSED,
);
let entries = relicario_core::safe_unpack_git_archive(tar_bytes, cap)
.with_context(|| "failed to safely unpack .git/ archive")?;
let git_dir = target.join(".git");
for (rel_path, body) in entries {
let dest = git_dir.join(&rel_path);
// Paranoid OS-level check even after textual validation in core.
if !dest.starts_with(&git_dir) {
anyhow::bail!(
"tar entry {} resolved outside .git/ (path traversal blocked)",
rel_path.display()
);
}
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent).with_context(|| {
format!("create parent {}", parent.display())
})?;
}
fs::write(&dest, &body).with_context(|| {
format!("write {}", dest.display())
})?;
}
} else {
// No history bundled — start a fresh git repo.
crate::helpers::git_run(&target, &["init"], "backup restore: git init")?;
// .gitignore — exclude reference image if present.
if target.join("reference.jpg").exists() {
fs::write(target.join(".gitignore"), "reference.jpg\n")?;
}
let _ = crate::helpers::git_command(&target, &["add", "."]).status()?;
let now_iso = crate::helpers::iso8601(relicario_core::now_unix());
let msg = format!("restore from backup {now_iso}");
let _ = crate::helpers::git_command(&target, &["commit", "-m", &msg]).status()?;
}
eprintln!(
"Restored vault to {}. Unlock with your passphrase + reference image.",
target.display()
);
Ok(())
}

View File

@@ -0,0 +1,255 @@
//! `relicario device {add, revoke, list}` — device key management.
//!
//! Note: command bodies live here as `crate::commands::device`. Local key
//! storage and git-signing config live separately in `crate::device`.
use anyhow::Result;
use crate::DeviceAction;
/// Build a `GiteaClient` from flags or environment variables.
fn load_gitea_client(
gitea_url: Option<String>,
gitea_token: Option<String>,
owner: Option<String>,
repo: Option<String>,
) -> Result<crate::gitea::GiteaClient> {
let url = gitea_url
.or_else(|| std::env::var("RELICARIO_GITEA_URL").ok())
.ok_or_else(|| anyhow::anyhow!(
"Gitea URL required — pass --gitea-url or set RELICARIO_GITEA_URL"
))?;
let token = gitea_token
.or_else(|| std::env::var("RELICARIO_GITEA_TOKEN").ok())
.ok_or_else(|| anyhow::anyhow!(
"Gitea token required — pass --gitea-token or set RELICARIO_GITEA_TOKEN"
))?;
let owner = owner
.or_else(|| std::env::var("RELICARIO_GITEA_OWNER").ok())
.ok_or_else(|| anyhow::anyhow!(
"Gitea owner required — pass --owner or set RELICARIO_GITEA_OWNER"
))?;
let repo = repo
.or_else(|| std::env::var("RELICARIO_GITEA_REPO").ok())
.ok_or_else(|| anyhow::anyhow!(
"Gitea repo required — pass --repo or set RELICARIO_GITEA_REPO"
))?;
Ok(crate::gitea::GiteaClient::new(&url, &token, &owner, &repo))
}
pub fn cmd_device(action: DeviceAction) -> Result<()> {
use std::fs;
use relicario_core::device::{DeviceEntry, RevokedEntry, generate_keypair};
let root = crate::helpers::vault_dir()?;
let relicario_dir = root.join(".relicario");
let devices_path = relicario_dir.join("devices.json");
match action {
DeviceAction::Add { name, gitea_url, gitea_token, owner, repo, no_gitea } => {
// Guard: don't overwrite an already-registered device name.
let existing: Vec<DeviceEntry> = fs::read(&devices_path)
.ok()
.and_then(|b| serde_json::from_slice(&b).ok())
.unwrap_or_default();
if existing.iter().any(|d| d.name == name) {
anyhow::bail!("a device named '{}' is already registered", name);
}
eprintln!("Generating signing keypair...");
let (signing_priv, signing_pub) = generate_keypair()
.map_err(|e| anyhow::anyhow!("generate signing keypair: {e}"))?;
eprintln!("Generating deploy keypair...");
let (deploy_priv, deploy_pub) = generate_keypair()
.map_err(|e| anyhow::anyhow!("generate deploy keypair: {e}"))?;
// Optionally register deploy key with Gitea.
let gitea_key_id: u64 = if no_gitea {
eprintln!("Skipping Gitea deploy key registration (--no-gitea).");
0
} else {
let client = load_gitea_client(gitea_url, gitea_token, owner, repo)?;
let key_title = format!("relicario-{}", name);
eprintln!("Registering deploy key '{}' with Gitea...", key_title);
client.create_deploy_key(&key_title, &deploy_pub)?
};
// Store keys locally with proper permissions.
crate::device::store_device_keys(
&name,
&signing_priv,
&signing_pub,
&deploy_priv,
&deploy_pub,
gitea_key_id,
)?;
// Mark as current device.
crate::device::set_current_device(&name)?;
// Configure git signing + SSH deploy key in the vault repo.
crate::device::configure_git_signing(&root, &name)?;
// Update devices.json.
let current_name = name.clone();
let mut devices = existing;
devices.push(DeviceEntry {
name: name.clone(),
public_key: signing_pub.clone(),
added_at: relicario_core::now_unix(),
added_by: current_name,
});
fs::create_dir_all(&relicario_dir)?;
fs::write(&devices_path, serde_json::to_string_pretty(&devices)?)?;
// Commit the update.
crate::helpers::git_run(
&root,
&["add", ".relicario/devices.json"],
&format!("device register \"{name}\": git add .relicario/devices.json"),
)?;
let msg = format!("device: register {}", name);
crate::helpers::git_run(
&root,
&["commit", "-m", &msg],
&format!("device register \"{name}\": git commit"),
)?;
eprintln!("Device '{}' registered.", name);
eprintln!("Signing public key:");
eprintln!(" {}", signing_pub);
if gitea_key_id != 0 {
eprintln!("Gitea deploy key ID: {}", gitea_key_id);
}
Ok(())
}
DeviceAction::Revoke { name } => {
// Guard: refuse to revoke the currently active device (would lock
// the user out). They must add another device first.
if let Some(current) = crate::device::current_device()? {
if current == name {
anyhow::bail!(
"cannot revoke the current device '{}' — you would lose \
push access. Register another device first.",
name
);
}
}
// Load devices.json.
let mut devices: Vec<DeviceEntry> = fs::read(&devices_path)
.ok()
.and_then(|b| serde_json::from_slice(&b).ok())
.unwrap_or_default();
let device = devices
.iter()
.find(|d| d.name == name)
.ok_or_else(|| anyhow::anyhow!("device '{}' not found", name))?
.clone();
// Remove from devices.json.
devices.retain(|d| d.name != name);
fs::write(&devices_path, serde_json::to_string_pretty(&devices)?)?;
// Append to revoked.json.
let revoked_path = relicario_dir.join("revoked.json");
let mut revoked: Vec<RevokedEntry> = fs::read(&revoked_path)
.ok()
.and_then(|b| serde_json::from_slice(&b).ok())
.unwrap_or_default();
let revoked_by = crate::device::current_device()?
.unwrap_or_else(|| "unknown".to_string());
revoked.push(RevokedEntry {
name: name.clone(),
public_key: device.public_key.clone(),
revoked_at: relicario_core::now_unix(),
revoked_by,
});
fs::write(&revoked_path, serde_json::to_string_pretty(&revoked)?)?;
// Delete deploy key from Gitea (best-effort — don't fail if it
// was already deleted or the config is missing).
if let Ok(key_id) = crate::device::load_gitea_key_id(&name) {
if key_id != 0 {
// Build client from env vars only (no flags in revoke).
match load_gitea_client(None, None, None, None) {
Ok(client) => {
if let Err(e) = client.delete_deploy_key(key_id) {
eprintln!(
"warning: failed to delete Gitea deploy key {}: {}",
key_id, e
);
} else {
eprintln!("Deleted Gitea deploy key {}.", key_id);
}
}
Err(_) => {
eprintln!(
"warning: Gitea env vars not set — deploy key {} \
not deleted from Gitea.",
key_id
);
}
}
}
}
// Commit devices.json + revoked.json (always both — revoked.json
// was just written above so it is guaranteed to exist).
let add_args = [
"add",
".relicario/devices.json",
".relicario/revoked.json",
];
crate::helpers::git_run(
&root,
&add_args,
&format!("device revoke \"{name}\": git add devices.json + revoked.json"),
)?;
let msg = format!("device: revoke {}", name);
crate::helpers::git_run(
&root,
&["commit", "-m", &msg],
&format!("device revoke \"{name}\": git commit"),
)?;
eprintln!("Device '{}' revoked.", name);
eprintln!("Revoked signing key: {}", device.public_key);
Ok(())
}
DeviceAction::List => {
let devices: Vec<DeviceEntry> = fs::read(&devices_path)
.ok()
.and_then(|b| serde_json::from_slice(&b).ok())
.unwrap_or_default();
let current = crate::device::current_device()?.unwrap_or_default();
if devices.is_empty() {
println!("No registered devices.");
return Ok(());
}
println!("{:<20} {:<20} SIGNING KEY (prefix)", "NAME", "ADDED");
println!("{}", "-".repeat(72));
for d in &devices {
let marker = if d.name == current { " *" } else { "" };
let added = crate::helpers::iso8601(d.added_at);
// Show only the first 40 chars of the public key line for readability.
let key_prefix: String = d.public_key.chars().take(40).collect();
println!("{:<20} {:<20} {}{}",
d.name, added, key_prefix, marker);
}
if !current.is_empty() {
println!("\n* = current device");
}
Ok(())
}
}
}

View File

@@ -0,0 +1,172 @@
//! `relicario edit <query>` — interactive per-type field editing with history capture.
use std::path::PathBuf;
use anyhow::{Context, Result};
use crate::parse::base32_decode_lenient;
use crate::prompt::{prompt_keep, prompt_keep_opt, prompt_secret, prompt_yesno};
pub fn cmd_edit(query: String, totp_qr: Option<PathBuf>) -> Result<()> {
use relicario_core::time::now_unix;
use relicario_core::ItemCore;
let vault = crate::session::UnlockedVault::unlock_interactive()?;
let mut manifest = vault.load_manifest()?;
let entry = super::resolve_query(&manifest, &query)?;
let id = entry.id.clone();
let _ = entry;
let mut item = vault.load_item(&id)?;
eprintln!("Editing: {} ({}) — leave a prompt blank to keep the current value.",
item.title, item.id.as_str());
if let Some(v) = prompt_keep("Title", &item.title)? { item.title = v; }
if let Some(v) = prompt_keep_opt("Group", item.group.as_deref())? { item.group = Some(v); }
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();
}
let history = &mut item.field_history;
match &mut item.core {
ItemCore::Login(l) => edit_login(l, history, totp_qr)?,
ItemCore::SecureNote(n) => edit_secure_note(n, history)?,
ItemCore::Identity(i) => edit_identity(i)?,
ItemCore::Card(c) => edit_card(c, history)?,
ItemCore::Key(k) => edit_key(k, history)?,
ItemCore::Document(_) => edit_document_message(),
ItemCore::Totp(t) => edit_totp(t, history)?,
}
item.modified = now_unix();
vault.save_item(&item)?;
manifest.upsert(&item);
vault.save_manifest(&manifest)?;
crate::refresh_groups_cache(vault.root(), &manifest);
super::commit_paths(&vault, &format!("edit: {} ({})", crate::helpers::sanitize_for_commit(&item.title), item.id.as_str()),
&[&format!("items/{}.enc", item.id.as_str()), "manifest.enc"])?;
eprintln!("Updated {}", item.id.as_str());
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,
totp_qr: Option<PathBuf>,
) -> Result<()> {
use relicario_core::item_types::{TotpAlgorithm, TotpConfig, TotpKind};
use zeroize::Zeroizing;
if let Some(v) = prompt_keep_opt("Username", l.username.as_deref())? { l.username = Some(v); }
if let Some(v) = prompt_keep_opt("URL", l.url.as_ref().map(|u| u.as_str()))? {
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()));
}
}
if let Some(path) = totp_qr {
let secret_b32 = crate::helpers::decode_totp_qr(&path)?;
let secret_bytes = base32_decode_lenient(&secret_b32)?;
l.totp = Some(TotpConfig {
secret: Zeroizing::new(secret_bytes),
algorithm: TotpAlgorithm::Sha1,
digits: 6,
period_seconds: 30,
kind: TotpKind::Totp,
});
eprintln!("TOTP secret set from QR image.");
}
Ok(())
}
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 push_history(
history: &mut std::collections::HashMap<relicario_core::FieldId, Vec<relicario_core::item::FieldHistoryEntry>>,
synthetic_key: &str,
old_value: zeroize::Zeroizing<String>,
) {
use relicario_core::item::FieldHistoryEntry;
use relicario_core::time::now_unix;
// Synthetic FieldId for core-level fields — stable per-item (prefixed so
// custom-field UUIDs can't collide).
let fid = relicario_core::FieldId(format!("core:{synthetic_key}"));
history.entry(fid).or_default().push(FieldHistoryEntry {
value: old_value,
replaced_at: now_unix(),
});
}

View File

@@ -0,0 +1,68 @@
//! `relicario generate` — emit a fresh password or BIP39 passphrase.
use anyhow::Result;
pub fn cmd_generate(
length: Option<u32>,
bip39: bool,
words: Option<u32>,
symbols: Option<String>,
separator: Option<String>,
) -> Result<()> {
use relicario_core::{
generate_passphrase, generate_password, Capitalization, CharClasses,
GeneratorRequest, SymbolCharset,
};
// 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 {
word_count: words.unwrap_or(def_words),
separator: separator.unwrap_or(def_sep),
capitalization: def_cap,
})?
} else {
let (def_length, def_classes, def_charset) = match &vault_defaults {
Some(GeneratorRequest::Random { length, classes, symbol_charset }) => {
(*length, *classes, symbol_charset.clone())
}
_ => (
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 {
length: length.unwrap_or(def_length),
classes: def_classes,
symbol_charset,
})?
};
println!("{}", output.as_str());
Ok(())
}

View File

@@ -0,0 +1,107 @@
//! `relicario get` — print a single item, masking secrets unless `--show`.
use anyhow::{Context, Result};
pub fn cmd_get(query: String, show: bool, copy: bool) -> Result<()> {
use relicario_core::ItemCore;
use zeroize::Zeroizing;
let vault = crate::session::UnlockedVault::unlock_interactive()?;
let manifest = vault.load_manifest()?;
crate::refresh_groups_cache(vault.root(), &manifest);
let entry = super::resolve_query(&manifest, &query)?;
let item = vault.load_item(&entry.id)?;
println!("ID: {}", item.id.as_str());
println!("Title: {}", item.title);
println!("Type: {:?}", item.r#type);
if let Some(g) = &item.group { println!("Group: {g}"); }
if !item.tags.is_empty() { println!("Tags: {}", item.tags.join(", ")); }
println!("Created: {}", crate::helpers::iso8601(item.created));
println!("Modified: {}", crate::helpers::iso8601(item.modified));
if let Some(t) = item.trashed_at { println!("Trashed: {}", crate::helpers::iso8601(t)); }
println!();
let primary_secret: Option<Zeroizing<String>> = match &item.core {
ItemCore::Login(l) => {
if let Some(u) = &l.username { println!("Username: {u}"); }
if let Some(u) = &l.url { println!("URL: {u}"); }
if let Some(t) = &l.totp {
if show {
println!("TOTP: {}", data_encoding::BASE32.encode(&t.secret));
} else {
println!("TOTP: **** (use --show to reveal)");
}
}
l.password.clone()
}
ItemCore::SecureNote(n) => {
if show { println!("Body:\n{}", n.body.as_str()); }
else { println!("Body: ********"); }
None
}
ItemCore::Identity(i) => {
if let Some(v) = &i.full_name { println!("Name: {v}"); }
if let Some(v) = &i.email { println!("Email: {v}"); }
if let Some(v) = &i.phone { println!("Phone: {v}"); }
if let Some(v) = &i.date_of_birth { println!("DOB: {v}"); }
None
}
ItemCore::Card(c) => {
if let Some(h) = &c.holder { println!("Holder: {h}"); }
if let Some(e) = &c.expiry { println!("Expiry: {:02}/{}", e.month, e.year); }
println!("Kind: {:?}", c.kind);
c.number.clone()
}
ItemCore::Key(k) => {
if let Some(l) = &k.label { println!("Label: {l}"); }
if let Some(a) = &k.algorithm { println!("Algo: {a}"); }
if let Some(pk) = &k.public_key { println!("Pubkey: {pk}"); }
Some(k.key_material.clone())
}
ItemCore::Document(d) => {
println!("Filename: {}", d.filename);
println!("MIME: {}", d.mime_type);
None
}
ItemCore::Totp(t) => {
if let Some(i) = &t.issuer { println!("Issuer: {i}"); }
if let Some(l) = &t.label { println!("Label: {l}"); }
println!("Period: {}s", t.config.period_seconds);
println!("Digits: {}", t.config.digits);
None
}
};
if let Some(secret) = primary_secret {
if show {
println!("Secret: {}", secret.as_str());
} else {
println!("Secret: ******** (use --show to reveal, --copy to clipboard)");
}
if copy {
copy_to_clipboard_then_clear(&secret)?;
eprintln!("Copied to clipboard (auto-clears in 30s).");
}
}
Ok(())
}
fn copy_to_clipboard_then_clear(secret: &zeroize::Zeroizing<String>) -> Result<()> {
use arboard::Clipboard;
let mut cb = Clipboard::new().context("failed to access clipboard")?;
cb.set_text(secret.as_str().to_string()).context("failed to write clipboard")?;
let cleared_copy = zeroize::Zeroizing::new(secret.as_str().to_owned());
// Unconditional clear (audit M6): spawn a detached thread that waits 30s
// and then rewrites the clipboard with empty string. Even if the user
// copies something else in the interim, we still overwrite once.
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_secs(30));
if let Ok(mut cb) = Clipboard::new() {
let _ = cb.set_text(String::new());
drop(cleared_copy); // zeroize the detached copy
}
});
Ok(())
}

View File

@@ -0,0 +1,88 @@
//! `relicario import` — currently only LastPass CSV is supported.
use std::path::PathBuf;
use anyhow::{bail, Context, Result};
use crate::ImportAction;
pub fn cmd_import(action: ImportAction) -> Result<()> {
match action {
ImportAction::Lastpass { csv } => cmd_import_lastpass(csv),
}
}
fn cmd_import_lastpass(csv_path: PathBuf) -> Result<()> {
use std::fs;
use relicario_core::import_lastpass::parse_lastpass_csv;
let csv_bytes = fs::read(&csv_path)
.with_context(|| format!("failed to read CSV {}", csv_path.display()))?;
let (items, warnings) = parse_lastpass_csv(&csv_bytes)?;
if items.is_empty() {
// Print all warnings so the user sees why nothing imported.
for w in &warnings {
print_warning(w);
}
bail!(
"imported 0 items from {} — see warnings above",
csv_path.display()
);
}
let vault = crate::session::UnlockedVault::unlock_interactive()?;
let mut manifest = vault.load_manifest()?;
let total = items.len();
let mut written_paths: Vec<String> = Vec::with_capacity(items.len() + 1);
for (idx, item) in items.iter().enumerate() {
vault.save_item(item)?;
manifest.upsert(item);
written_paths.push(format!("items/{}.enc", item.id.as_str()));
let n = idx + 1;
if n % 50 == 0 || n == total {
eprintln!("[{n}/{total}] importing...");
}
}
vault.save_manifest(&manifest)?;
written_paths.push("manifest.enc".into());
let path_refs: Vec<&str> = written_paths.iter().map(String::as_str).collect();
let csv_filename = csv_path
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("lastpass.csv");
super::commit_paths(
&vault,
&format!("import: {} items from LastPass ({})", total, csv_filename),
&path_refs,
)?;
for w in &warnings {
print_warning(w);
}
// Counts only true skips, not partial imports. Coupled by convention to
// the parser's warning message strings: skip messages end in "— skipped",
// partial-import messages say "imported without TOTP" / "imported without URL".
// If a future warning uses the word "skipped" in any other sense, this filter
// will need to switch to an enum tag (see ImportWarning::message).
eprintln!(
"Imported {}, skipped {} (see warnings above)",
total,
warnings.iter().filter(|w| w.message.contains("skipped")).count()
);
Ok(())
}
fn print_warning(w: &relicario_core::import_lastpass::ImportWarning) {
let prefix = match &w.title {
Some(t) => format!("row {} ({}):", w.row, t),
None => format!("row {}:", w.row),
};
eprintln!("warning: {prefix} {}", w.message);
}

View File

@@ -0,0 +1,125 @@
//! `relicario init` — bootstrap a fresh vault in the current directory.
use std::path::PathBuf;
use anyhow::{Context, Result};
pub fn cmd_init(image: PathBuf, output: PathBuf) -> Result<()> {
use std::fs;
use rand::{rngs::OsRng, RngCore};
use relicario_core::{
derive_master_key, encrypt_manifest, encrypt_settings, imgsecret,
validate_passphrase_strength, KdfParams, Manifest, VaultSettings,
};
use zeroize::Zeroizing;
let root = std::env::current_dir()?;
let relicario_dir = root.join(".relicario");
if relicario_dir.exists() {
anyhow::bail!(".relicario/ already exists in {}", root.display());
}
// Passphrase with strength gate (audit H3).
// RELICARIO_TEST_PASSPHRASE is a test-only escape hatch that bypasses the
// TTY prompt so integration tests can run without a real TTY.
let passphrase = if let Some(p) = crate::test_passphrase_override() {
Zeroizing::new(p)
} else {
Zeroizing::new(rpassword::prompt_password("Choose a passphrase: ")?)
};
let confirm = if crate::test_passphrase_override().is_some() {
passphrase.clone()
} else {
Zeroizing::new(rpassword::prompt_password("Confirm passphrase: ")?)
};
if passphrase.as_str() != confirm.as_str() {
anyhow::bail!("passphrases do not match");
}
if let Err(e) = validate_passphrase_strength(&passphrase) {
anyhow::bail!("{}. Choose a longer or more entropic phrase.", e);
}
// Image secret: 32 random bytes, embedded in the carrier.
let image_secret = {
let mut buf = Zeroizing::new([0u8; 32]);
OsRng.fill_bytes(buf.as_mut_slice());
buf
};
let carrier = fs::read(&image)
.with_context(|| format!("failed to read carrier image {}", image.display()))?;
let stego = imgsecret::embed(&carrier, &image_secret)?;
fs::write(&output, &stego)
.with_context(|| format!("failed to write reference image {}", output.display()))?;
// Vault salt + KDF params.
let mut salt = [0u8; 32];
OsRng.fill_bytes(&mut salt);
let params = KdfParams { argon2_m: 65536, argon2_t: 3, argon2_p: 4 };
// Derive master key, then persist an empty Manifest + default VaultSettings.
let master_key = derive_master_key(passphrase.as_bytes(), &image_secret, &salt, &params)?;
fs::create_dir_all(&relicario_dir)?;
fs::create_dir_all(root.join("items"))?;
fs::create_dir_all(root.join("attachments"))?;
fs::write(relicario_dir.join("salt"), salt)?;
fs::write(
relicario_dir.join("params.json"),
serde_json::to_string_pretty(&ParamsFile {
format_version: 2,
kdf: ParamsKdf {
algorithm: "argon2id-v0x13".into(),
argon2_m: params.argon2_m,
argon2_t: params.argon2_t,
argon2_p: params.argon2_p,
},
aead: "xchacha20poly1305".into(),
salt_path: ".relicario/salt".into(),
})?,
)?;
let manifest = Manifest::new();
fs::write(root.join("manifest.enc"), encrypt_manifest(&manifest, &master_key)?)?;
let settings = VaultSettings::default();
fs::write(root.join("settings.enc"), encrypt_settings(&settings, &master_key)?)?;
// .gitignore excludes the reference image.
let fname = output.file_name()
.ok_or_else(|| anyhow::anyhow!("output path has no filename: {}", output.display()))?
.to_string_lossy();
let gitignore = format!("{fname}\n");
fs::write(root.join(".gitignore"), gitignore)?;
// git init + initial commit via hardened wrapper.
crate::helpers::git_run(&root, &["init"], "init: git init")?;
let _ = crate::helpers::git_command(&root, &[
"add", ".gitignore", ".relicario/params.json",
".relicario/salt", "manifest.enc", "settings.enc",
]).status()?;
crate::helpers::git_run(
&root,
&["commit", "-m", "init: new Relicario vault (format v2)"],
"init: git commit",
)?;
eprintln!("Vault initialized at {}", root.display());
eprintln!("Reference image: {}", output.display());
eprintln!(" \u{2192} back this file up somewhere safe; it is your second factor.");
Ok(())
}
#[derive(serde::Serialize)]
struct ParamsFile {
format_version: u32,
kdf: ParamsKdf,
aead: String,
salt_path: String,
}
#[derive(serde::Serialize)]
#[serde(rename_all = "snake_case")]
struct ParamsKdf {
algorithm: String,
argon2_m: u32,
argon2_t: u32,
argon2_p: u32,
}

View File

@@ -0,0 +1,103 @@
//! `relicario list` and `relicario history` — both read-only browse paths.
use anyhow::Result;
pub fn cmd_list(
type_filter: Option<String>,
group_filter: Option<String>,
tag_filter: Option<String>,
trashed: bool,
) -> Result<()> {
use relicario_core::ItemType;
let vault = crate::session::UnlockedVault::unlock_interactive()?;
let manifest = vault.load_manifest()?;
crate::refresh_groups_cache(vault.root(), &manifest);
let parsed_type: Option<ItemType> = match type_filter.as_deref() {
None => None,
Some("login") => Some(ItemType::Login),
Some("secure_note") | Some("note") => Some(ItemType::SecureNote),
Some("identity") => Some(ItemType::Identity),
Some("card") => Some(ItemType::Card),
Some("key") => Some(ItemType::Key),
Some("document") => Some(ItemType::Document),
Some("totp") => Some(ItemType::Totp),
Some(other) => anyhow::bail!("unknown type filter: {other}"),
};
let mut entries: Vec<_> = manifest.items.values()
.filter(|e| {
if trashed { e.trashed_at.is_some() } else { e.trashed_at.is_none() }
})
.filter(|e| match parsed_type {
Some(t) => e.r#type == t,
None => true,
})
.filter(|e| group_filter.as_ref().is_none_or(|g| e.group.as_deref() == Some(g.as_str())))
.filter(|e| tag_filter.as_ref().is_none_or(|t| e.tags.iter().any(|x| x == t)))
.collect();
entries.sort_by(|a, b| a.title.to_lowercase().cmp(&b.title.to_lowercase()));
if entries.is_empty() {
eprintln!("(no items match)");
return Ok(());
}
println!("{:<16} {:<14} {:<6} TITLE", "ID", "TYPE", "FAV");
for e in entries {
let fav = if e.favorite { " *" } else { "" };
println!("{:<16} {:<14} {:<6} {}", e.id.as_str(), format!("{:?}", e.r#type), fav, e.title);
}
Ok(())
}
pub 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 = super::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(())
}

View File

@@ -0,0 +1,60 @@
//! Per-command modules — one file per top-level subcommand.
//!
//! `main.rs` holds the clap surface (argument enums) and the dispatch
//! `match`; the actual command bodies live here. Helpers shared between
//! command modules (e.g. `commit_paths`, `resolve_query`) are defined in
//! this file as `pub(crate)` so siblings can pull them in via
//! `use crate::commands::*`.
pub mod add;
pub mod attach;
pub mod backup;
pub mod device;
pub mod edit;
pub mod generate;
pub mod get;
pub mod import;
pub mod init;
pub mod list;
pub mod rate;
pub mod recovery_qr;
pub mod settings;
pub mod status;
pub mod sync;
pub mod trash;
use anyhow::Result;
pub(crate) fn commit_paths(
vault: &crate::session::UnlockedVault,
message: &str,
paths: &[&str],
) -> Result<()> {
let mut args: Vec<&str> = vec!["add"];
args.extend_from_slice(paths);
crate::helpers::git_run(vault.root(), &args, &format!("commit \"{message}\": git add"))?;
crate::helpers::git_run(
vault.root(),
&["commit", "-m", message],
&format!("commit \"{message}\": git commit"),
)?;
Ok(())
}
pub(crate) fn resolve_query<'a>(
manifest: &'a relicario_core::Manifest,
query: &str,
) -> Result<&'a relicario_core::ManifestEntry> {
if let Some(entry) = manifest.items.values().find(|e| e.id.as_str() == query) {
return Ok(entry);
}
let hits: Vec<_> = manifest.search(query);
match hits.len() {
0 => anyhow::bail!("no item matches `{query}`"),
1 => Ok(hits[0]),
_ => {
let titles: Vec<&str> = hits.iter().map(|e| e.title.as_str()).collect();
anyhow::bail!("ambiguous — {} matches: {}", hits.len(), titles.join(", "))
}
}
}

View File

@@ -0,0 +1,28 @@
//! `relicario rate` — score a passphrase via zxcvbn.
use anyhow::Result;
pub fn cmd_rate(passphrase: String) -> Result<()> {
let pw: String = if passphrase == "-" {
use std::io::BufRead;
let stdin = std::io::stdin();
let mut line = String::new();
stdin.lock().read_line(&mut line)?;
line.trim_end_matches(&['\r', '\n'][..]).to_string()
} else {
passphrase
};
let est = relicario_core::generators::rate_passphrase(&pw);
let label = match est.score {
0 => "very weak",
1 => "weak",
2 => "fair",
3 => "good",
4 => "strong",
_ => "?",
};
println!("score: {}/4 ({})", est.score, label);
println!("guesses: ~10^{:.1}", est.guesses_log10);
println!("note: init requires score ≥ 3 (see `relicario init`)");
Ok(())
}

View File

@@ -0,0 +1,69 @@
//! `relicario recovery-qr {generate,unwrap}` — last-resort vault-key escape hatch.
use anyhow::{Context, Result};
use crate::RecoveryQrCmd;
pub fn cmd_recovery_qr(cmd: RecoveryQrCmd) -> Result<()> {
match cmd {
RecoveryQrCmd::Generate => cmd_recovery_qr_generate(),
RecoveryQrCmd::Unwrap => cmd_recovery_qr_unwrap(),
}
}
fn cmd_recovery_qr_generate() -> Result<()> {
use relicario_core::{generate_recovery_qr, imgsecret};
use zeroize::Zeroizing;
let image_path = crate::session::get_image_path()?;
let image_bytes = std::fs::read(&image_path)
.with_context(|| format!("read reference image {}", image_path.display()))?;
let image_secret = imgsecret::extract(&image_bytes)
.context("extract image secret")?;
let passphrase = Zeroizing::new(
rpassword::prompt_password("Enter vault passphrase: ")
.context("read passphrase")?
);
let payload = generate_recovery_qr(passphrase.as_str(), &image_secret)
.map_err(|e| anyhow::anyhow!("{e}"))?;
use qrcode::{EcLevel, QrCode, render::unicode};
let code = QrCode::with_error_correction_level(payload.as_bytes(), EcLevel::M)
.expect("valid payload");
let image = code
.render::<unicode::Dense1x2>()
.dark_color(unicode::Dense1x2::Dark)
.light_color(unicode::Dense1x2::Light)
.build();
println!("{image}");
println!("Recovery QR generated. Print or photograph this code and store it securely.");
println!("The QR has NOT been saved to disk.");
Ok(())
}
fn cmd_recovery_qr_unwrap() -> Result<()> {
use relicario_core::unwrap_recovery_qr;
use std::io::BufRead;
use zeroize::Zeroizing;
println!("Paste the base64 recovery QR payload and press Enter:");
let stdin = std::io::stdin();
let payload_b64 = stdin.lock().lines().next()
.context("no input")??;
let payload_b64 = payload_b64.trim().to_owned();
let bytes = data_encoding::BASE64.decode(payload_b64.as_bytes())
.map_err(|e| anyhow::anyhow!("base64 decode: {e}"))?;
let passphrase = Zeroizing::new(
rpassword::prompt_password("Enter passphrase: ")
.context("read passphrase")?
);
let secret = unwrap_recovery_qr(&bytes, passphrase.as_str())
.map_err(|e| anyhow::anyhow!("{e}"))?;
println!("image_secret: {}", hex::encode(secret.as_ref()));
Ok(())
}

View File

@@ -0,0 +1,98 @@
//! `relicario settings {show, trash-retention, history-retention, attachment-cap, generator-defaults}`.
use anyhow::Result;
use crate::SettingsAction;
pub fn cmd_settings(action: SettingsAction) -> Result<()> {
use relicario_core::{
Capitalization, CharClasses, GeneratorRequest, HistoryRetention,
SymbolCharset, TrashRetention,
};
let vault = crate::session::UnlockedVault::unlock_interactive()?;
let mut settings = vault.load_settings()?;
match action {
SettingsAction::Show => {
println!("{}", serde_json::to_string_pretty(&settings)?);
return Ok(());
}
SettingsAction::TrashRetention { days, forever } => {
settings.trash_retention = match (days, forever) {
(Some(d), false) => TrashRetention::Days(d),
(None, true) => TrashRetention::Forever,
_ => anyhow::bail!("specify exactly one of --days or --forever"),
};
}
SettingsAction::HistoryRetention { last_n, days, forever } => {
settings.field_history_retention = match (last_n, days, forever) {
(Some(n), None, false) => HistoryRetention::LastN(n),
(None, Some(d), false) => HistoryRetention::Days(d),
(None, None, true) => HistoryRetention::Forever,
_ => anyhow::bail!("specify exactly one of --last-n / --days / --forever"),
};
}
SettingsAction::AttachmentCap {
per_attachment_max_bytes, per_item_max_count,
per_vault_soft_cap_bytes, per_vault_hard_cap_bytes,
} => {
if let Some(v) = per_attachment_max_bytes { settings.attachment_caps.per_attachment_max_bytes = v; }
if let Some(v) = per_item_max_count { settings.attachment_caps.per_item_max_count = 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; }
}
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)?;
super::commit_paths(&vault, "settings: update", &["settings.enc"])?;
eprintln!("Settings updated.");
Ok(())
}

View File

@@ -0,0 +1,52 @@
//! `relicario status` — vault-level summary (counts, last commit, last backup).
use anyhow::Result;
pub fn cmd_status() -> Result<()> {
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));
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());
// Last backup age (read from marker written by cmd_backup_export).
let last_backup_path = vault.root().join(".relicario").join("last_backup");
let last_backup_str = if last_backup_path.exists() {
let line = std::fs::read_to_string(&last_backup_path)
.unwrap_or_default()
.trim()
.to_string();
// Parse the ISO-8601 we wrote in cmd_backup_export.
match chrono::DateTime::parse_from_rfc3339(&line) {
Ok(then) => {
let now = relicario_core::now_unix();
let age = now - then.timestamp();
crate::helpers::humanize_age(age.max(0))
}
Err(_) => "unknown".to_string(),
}
} else {
"never".to_string()
};
println!("Vault: {}", root.display());
println!("Items: {total_items} total ({active_items} active, {trashed_items} trashed)");
println!("Attachments: {attachment_count} ({attachment_bytes} bytes)");
println!("Last commit: {last_commit}");
println!("Last export: {last_backup_str}");
Ok(())
}

View File

@@ -0,0 +1,11 @@
//! `relicario sync` — pull --rebase + push.
use anyhow::Result;
pub fn cmd_sync() -> Result<()> {
let root = crate::helpers::vault_dir()?;
crate::helpers::git_run(&root, &["pull", "--rebase"], "sync: git pull --rebase")?;
crate::helpers::git_run(&root, &["push"], "sync: git push")?;
eprintln!("Sync complete.");
Ok(())
}

View File

@@ -0,0 +1,139 @@
//! Trash umbrella: `rm` (soft-delete), `restore`, `purge` (permanent),
//! `trash list` / `trash empty`.
use anyhow::Result;
use crate::TrashAction;
pub fn cmd_rm(query: String) -> Result<()> {
let vault = crate::session::UnlockedVault::unlock_interactive()?;
let mut manifest = vault.load_manifest()?;
let entry = super::resolve_query(&manifest, &query)?;
let id = entry.id.clone();
let _ = entry;
let mut item = vault.load_item(&id)?;
item.soft_delete();
vault.save_item(&item)?;
manifest.upsert(&item);
vault.save_manifest(&manifest)?;
crate::refresh_groups_cache(vault.root(), &manifest);
super::commit_paths(&vault, &format!("trash: {} ({})", crate::helpers::sanitize_for_commit(&item.title), item.id.as_str()),
&[&format!("items/{}.enc", item.id.as_str()), "manifest.enc"])?;
eprintln!("Moved to trash: {}", item.title);
Ok(())
}
pub fn cmd_restore(query: String) -> Result<()> {
let vault = crate::session::UnlockedVault::unlock_interactive()?;
let mut manifest = vault.load_manifest()?;
let entry = super::resolve_query(&manifest, &query)?;
let id = entry.id.clone();
let _ = entry;
let mut item = vault.load_item(&id)?;
item.restore();
vault.save_item(&item)?;
manifest.upsert(&item);
vault.save_manifest(&manifest)?;
crate::refresh_groups_cache(vault.root(), &manifest);
super::commit_paths(&vault, &format!("restore: {} ({})", crate::helpers::sanitize_for_commit(&item.title), item.id.as_str()),
&[&format!("items/{}.enc", item.id.as_str()), "manifest.enc"])?;
eprintln!("Restored: {}", item.title);
Ok(())
}
/// Inner purge: assumes vault is already unlocked and manifest is loaded.
/// Caller is responsible for saving the manifest and committing afterwards.
pub(super) fn purge_item(
vault: &crate::session::UnlockedVault,
manifest: &mut relicario_core::Manifest,
id: &relicario_core::ItemId,
title: &str,
) -> Result<()> {
use std::fs;
let item_path = vault.item_path(id);
if item_path.exists() { fs::remove_file(&item_path)?; }
let att_dir = vault.root().join("attachments").join(id.as_str());
if att_dir.exists() { fs::remove_dir_all(&att_dir)?; }
manifest.remove(id);
let _ = crate::helpers::git_command(vault.root(), &["rm", "-rf", "--ignore-unmatch",
&format!("items/{}.enc", id.as_str()),
&format!("attachments/{}", id.as_str()),
]).status()?;
// Note: caller adds+commits manifest.enc after processing all purges.
eprintln!("Purged: {title}");
Ok(())
}
pub fn cmd_purge(query: String) -> Result<()> {
let vault = crate::session::UnlockedVault::unlock_interactive()?;
let mut manifest = vault.load_manifest()?;
let entry = super::resolve_query(&manifest, &query)?;
let id = entry.id.clone();
let title = entry.title.clone();
let _ = entry;
purge_item(&vault, &mut manifest, &id, &title)?;
vault.save_manifest(&manifest)?;
crate::refresh_groups_cache(vault.root(), &manifest);
let purge_ctx = format!("purge \"{}\" ({})", title, id.as_str());
crate::helpers::git_run(vault.root(), &["add", "manifest.enc"], &format!("{purge_ctx}: git add manifest.enc"))?;
crate::helpers::git_run(
vault.root(),
&["commit", "-m", &format!("purge: {} ({})", title, id.as_str())],
&format!("{purge_ctx}: git commit"),
)?;
Ok(())
}
pub fn cmd_trash(action: TrashAction) -> Result<()> {
match action {
TrashAction::List => super::list::cmd_list(None, None, None, true),
TrashAction::Empty => cmd_trash_empty(),
}
}
pub fn cmd_trash_empty() -> Result<()> {
use relicario_core::time::now_unix;
let vault = crate::session::UnlockedVault::unlock_interactive()?;
let mut manifest = vault.load_manifest()?;
let settings = vault.load_settings()?;
let now = now_unix();
let purgeable: Vec<_> = manifest.items.values()
.filter(|e| match e.trashed_at {
Some(t) => settings.trash_retention.should_purge(t, now),
None => false,
})
.map(|e| (e.id.clone(), e.title.clone()))
.collect();
if purgeable.is_empty() {
eprintln!("nothing past retention window");
return Ok(());
}
let mut purged_titles = Vec::new();
for (id, title) in purgeable {
purge_item(&vault, &mut manifest, &id, &title)?;
purged_titles.push(title);
}
vault.save_manifest(&manifest)?;
crate::helpers::git_run(
vault.root(),
&["add", "manifest.enc"],
"trash empty: git add manifest.enc",
)?;
crate::helpers::git_run(
vault.root(),
&["commit", "-m", &format!("trash empty: purged {} item(s)", purged_titles.len())],
"trash empty: git commit",
)?;
eprintln!("Emptied trash: {} item(s)", purged_titles.len());
Ok(())
}

View File

@@ -55,6 +55,37 @@ pub fn git_command(repo: &Path, args: &[&str]) -> Command {
cmd
}
/// Run `git <args>` in `repo` with the same hardening as `git_command`,
/// capturing stdout/stderr and reproducing them on failure so the caller
/// sees git's exact diagnostic instead of just a verb.
///
/// `context` should be a short caller-supplied label like `"commit add: <id>"`
/// or `"sync: git push"`; it prefixes the bail message so the failing call is
/// identifiable from the error alone.
///
/// Trade-off vs. `git_command(...).status()`: this captures the child's stderr
/// (so live progress disappears during long-running fetches/pushes) but the
/// captured chunk is replayed verbatim on failure. The win is that
/// non-interactive callers (tests, hooks, CI, redirected stdout) finally see
/// pre-receive rejections, signing-key prompts, and dirty-tree complaints
/// instead of one-line "git X failed" bails. Use `git_command` directly when
/// live streaming is required.
pub fn git_run(repo: &Path, args: &[&str], context: &str) -> Result<()> {
let output = git_command(repo, args)
.output()
.with_context(|| format!("{context}: failed to spawn git"))?;
if !output.status.success() {
if !output.stdout.is_empty() {
eprint!("{}", String::from_utf8_lossy(&output.stdout));
}
if !output.stderr.is_empty() {
eprint!("{}", String::from_utf8_lossy(&output.stderr));
}
bail!("{context}: git failed ({})", output.status);
}
Ok(())
}
/// Format a Unix-seconds timestamp as an ISO-8601 UTC string.
/// Audit M11: replaces the old `now_iso8601` helper that actually returned
/// a numeric string.
@@ -220,6 +251,24 @@ mod tests {
assert_eq!(sanitize_for_commit("emoji \u{1F4AA}"), "emoji \u{1F4AA}");
}
#[test]
fn git_run_bails_with_context_on_failure() {
// Empty tempdir — `git status` will fail with "not a git repository".
let tmp = TempDir::new().unwrap();
let err = git_run(tmp.path(), &["status"], "test_ctx").unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("test_ctx"), "context not in error: {msg}");
assert!(msg.contains("git failed"), "missing failure marker: {msg}");
}
#[test]
fn git_run_succeeds_for_a_zero_exit_command() {
// `git --version` always succeeds and is independent of cwd.
let tmp = TempDir::new().unwrap();
git_run(tmp.path(), &["--version"], "version probe")
.expect("git --version should succeed");
}
#[test]
fn humanize_age_buckets() {
assert_eq!(humanize_age(0), "just now");

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,47 @@
//! Small parsers used by the CLI (`MM/YY[YY]`, lenient base32, MIME guess).
//!
//! Phase 7 of the CLI restructure migrates these to `relicario-core` and
//! turns this file into a thin re-export shim. They live here for now so
//! the Phase 1 relocation stays mechanical.
use anyhow::{Context, Result};
pub(crate) fn parse_month_year(s: &str) -> Result<relicario_core::MonthYear> {
// Accepts MM/YYYY or MM-YYYY or MM/YY.
let (m_str, y_str) = s.split_once(['/', '-'])
.ok_or_else(|| anyhow::anyhow!("expected MM/YYYY"))?;
let month: u8 = m_str.parse().context("invalid month")?;
let year: u16 = if y_str.len() == 2 {
2000 + y_str.parse::<u16>().context("invalid 2-digit year")?
} else {
y_str.parse().context("invalid year")?
};
Ok(relicario_core::MonthYear { month, year })
}
pub(crate) fn guess_mime(filename: &str) -> String {
let lower = filename.to_ascii_lowercase();
match lower.rsplit_once('.').map(|(_, ext)| ext).unwrap_or("") {
"pdf" => "application/pdf",
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
"txt" => "text/plain",
"json" => "application/json",
_ => "application/octet-stream",
}.to_string()
}
pub(crate) fn base32_decode_lenient(s: &str) -> Result<Vec<u8>> {
let cleaned: String = s.chars()
.filter(|c| !c.is_whitespace())
.collect::<String>()
.to_ascii_uppercase()
.trim_end_matches('=')
.to_string();
let padded = {
let rem = cleaned.len() % 8;
if rem == 0 { cleaned } else { format!("{}{}", cleaned, "=".repeat(8 - rem)) }
};
data_encoding::BASE32.decode(padded.as_bytes())
.map_err(|e| anyhow::anyhow!("invalid base32: {e}"))
}

View File

@@ -0,0 +1,65 @@
//! Interactive prompt helpers for the CLI.
//!
//! The `prompt`/`prompt_optional`/`prompt_secret` family reads from stdin /
//! the TTY; the `prompt_keep`/`prompt_keep_opt`/`prompt_yesno` variants are
//! used by the edit handlers to keep current values when the user hits enter
//! at a blank prompt. `prompt_secret` honours `RELICARIO_TEST_ITEM_SECRET`
//! so integration tests (which don't have a TTY) can inject secrets.
use anyhow::Result;
/// `rpassword::prompt_password` wrapper that honours `RELICARIO_TEST_ITEM_SECRET`
/// for integration-test use (rpassword reads /dev/tty by default, which is
/// unavailable in assert_cmd-spawned children).
pub(crate) fn prompt_secret(label: &str) -> Result<String> {
if let Some(s) = crate::test_item_secret_override() {
return Ok(s);
}
rpassword::prompt_password(label).map_err(Into::into)
}
pub(crate) fn prompt(label: &str) -> Result<String> {
eprint!("{label}: ");
std::io::Write::flush(&mut std::io::stderr())?;
let mut s = String::new();
std::io::stdin().read_line(&mut s)?;
let trimmed = s.trim().to_string();
if trimmed.is_empty() { anyhow::bail!("{label} required"); }
Ok(trimmed)
}
pub(crate) fn prompt_optional(label: &str) -> Result<Option<String>> {
eprint!("{label} (leave blank to skip): ");
std::io::Write::flush(&mut std::io::stderr())?;
let mut s = String::new();
std::io::stdin().read_line(&mut s)?;
let trimmed = s.trim().to_string();
Ok(if trimmed.is_empty() { None } else { Some(trimmed) })
}
pub(crate) fn prompt_keep(label: &str, current: &str) -> Result<Option<String>> {
eprint!("{label} [{current}]: ");
std::io::Write::flush(&mut std::io::stderr())?;
let mut s = String::new();
std::io::stdin().read_line(&mut s)?;
let trimmed = s.trim().to_string();
Ok(if trimmed.is_empty() { None } else { Some(trimmed) })
}
pub(crate) fn prompt_keep_opt(label: &str, current: Option<&str>) -> Result<Option<String>> {
let display = current.unwrap_or("(none)");
eprint!("{label} [{display}]: ");
std::io::Write::flush(&mut std::io::stderr())?;
let mut s = String::new();
std::io::stdin().read_line(&mut s)?;
let trimmed = s.trim().to_string();
Ok(if trimmed.is_empty() { None } else { Some(trimmed) })
}
pub(crate) fn prompt_yesno(label: &str) -> Result<bool> {
eprint!("{label} [y/N] ");
std::io::Write::flush(&mut std::io::stderr())?;
let mut s = String::new();
std::io::stdin().read_line(&mut s)?;
Ok(matches!(s.trim().to_ascii_lowercase().as_str(), "y" | "yes"))
}