4 commits from feature/cli-tail-stream-b-session-manifest: -2e41e0brefactor(cli): single canonical ParamsFile in session.rs (Phase 5) -7901c27refactor(cli): Vault::after_manifest_change wrapper (Phase 4) -4b657e7refactor(cli): batched purge in cmd_purge and cmd_trash_empty (Phase 6) -c4777ccrefactor(cli): apply simplify findings (Phases 4-6 polish) Phase 4 complete: Vault::after_manifest_change wrapper funnels NINE manifest- mutation sites (not 7 as the spec/notes flagged -- attach.rs add+detach, import.rs LastPass, and trash.rs cmd_trash_empty all previously SKIPPED refresh_groups_cache; the wrapper now refreshes them as a side-effect). save_manifest was DROPPED entirely (rather than just demoted to pub(crate) as the spec said) -- the simplify pass found no escape hatch was needed, so the only path to write the manifest now goes through the wrapper. Stronger than spec. Phase 5 complete: single pub(crate) struct ParamsFile in session.rs at module level with Serialize+Deserialize. Constructors for_new_vault and to_kdf_params (simplify pass changed into_kdf_params(self) to to_kdf_params(&self) for ergonomics). commands/init.rs uses ParamsFile::for_new_vault. On-disk JSON schema verified BYTE-STABLE via fixture-string round-trip test (session::tests::params_file_round_trips_current_layout + for_new_vault_produces_expected_shape) -- same fields, same ordering, same rename_all placement. Existing vaults read with no migration. Phase 6 complete: purge_item renamed purge_item_filesystem, mutates only filesystem + manifest, returns Vec<String> of paths. cmd_purge and cmd_trash_empty both follow after_manifest_change -> git_rm -> git add -> git commit. New helpers::git_rm extracted to DRY the pattern. Strict invariant locked: tests/basic_flows.rs::trash_empty_batches_into_one_commit counts commits via git rev-list --count HEAD before/after and asserts delta == 1. A 50-item trash empty now fires 3 git invocations, not 52. Simplify polish (c4777cc): all 5 findings legitimate, none rationale-skipped: - Dropped redundant save_manifest_raw escape hatch - Value-vs-self ergonomic fix (to_kdf_params(&self)) - DRY git_rm helper - TOCTOU pre-check dropped from purge_item_filesystem - Comment trim 3-way merge with stream-a (3dd1e1b) and stream-c (e69b347) clean: git auto-resolved commands/add.rs (stream-a prompt_or_flag changes interleaved with stream-b after_manifest_change call at the manifest-mutation site). Verified semantic correctness via post-merge cargo test. Pre-merge checklist on tipc4777cc+ post-merge verification: - cargo test --workspace standalone: 260 tests, 0 failures - cargo test --workspace post-merge: 281 tests, 0 failures - cargo clippy --workspace --all-targets -- -D warnings: silent - cargo build -p relicario-wasm --target wasm32-unknown-unknown: clean - Independent fresh-subagent code review: APPROVE - grep refresh_groups_cache crates/relicario-cli/src/: zero matches outside session.rs/helpers.rs (per spec done-criteria) - grep struct ParamsFile crates/relicario-cli/src/: ONE match (per spec done-criteria) Plan B COMPLETE. With Phase 3 (Stream A) merged at3dd1e1band Phases 7+8 (Stream C) merged ate69b347, all eight Plan B phases are now on main. One nit deferred (per subagent review): trash empty partial-failure recovery -- if git_rm fails after after_manifest_change succeeds, manifest.enc is rewritten in-tree and items are removed from disk but no commit is made. Pre-existing behavior was strictly worse (per-item interleaved partial-commit risk); current state is a net improvement. Tree-cleanup-on-failure belongs in a follow-up plan, not this PR. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
314 lines
11 KiB
Rust
314 lines
11 KiB
Rust
//! `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_or_flag, prompt_or_flag_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.after_manifest_change(&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 = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
|
|
let username = prompt_or_flag_optional(username, "Username", |s| Ok(s.to_string()))?;
|
|
let url = prompt_or_flag_optional(url, "URL", |s| Ok(s.to_string()))?;
|
|
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 = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
|
|
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 = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
|
|
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 = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
|
|
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 = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
|
|
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 = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
|
|
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 = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
|
|
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)
|
|
}
|