Compare commits
8 Commits
feature/v0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b09e0ce036 | ||
|
|
d8b23d421e | ||
|
|
6eb1275710 | ||
|
|
751e4e9bb1 | ||
|
|
db4e05a193 | ||
|
|
65e23cfddc | ||
|
|
b83643ee0a | ||
|
|
154b984725 |
@@ -37,15 +37,28 @@ under `src/commands/`. Each source file has one job.
|
||||
`cmd_history`), `edit`, `trash` (rm / restore / purge / trash empty),
|
||||
`backup` (export / restore), `import` (lastpass), `attach` (attach /
|
||||
attachments / extract / detach), `generate`, `settings`, `sync`, `status`,
|
||||
`rate`, `device`, `recovery_qr`. `add` and `edit` each fan out internally to
|
||||
per-`ItemCore` helpers (`build_<type>_item`, `edit_<type>`) so each
|
||||
builder/editor reads top-to-bottom and can be tested through the same
|
||||
integration paths.
|
||||
`rate`, `device`, `recovery_qr`. `add` and `edit` resolve their non-secret
|
||||
fields then delegate to the shared `item_build` module's per-`ItemCore`
|
||||
`build_*` / `edit_*` helpers (see the next bullet), so each builder/editor
|
||||
reads top-to-bottom and can be tested through the same integration paths.
|
||||
|
||||
- **`src/commands/item_build.rs`** — shared per-type item construction and
|
||||
interactive editing used by BOTH personal (`add.rs`, `edit.rs`) and org
|
||||
(`org.rs`) handlers, so the two surfaces cannot drift. Contains: secret
|
||||
resolution (`resolve_secret_line` — reads one line from stdin or falls back
|
||||
to an interactive masked prompt; `resolve_secret_multiline` — reads stdin to
|
||||
EOF, printing an optional hint in the interactive case); type parsers
|
||||
(`parse_card_kind`, `parse_totp_algorithm`); the seven `build_*` builders
|
||||
(`build_login`, `build_secure_note`, `build_identity`, `build_card`,
|
||||
`build_key`, `build_document`, `build_totp`); per-type `edit_*` helpers
|
||||
(`edit_login`, `edit_secure_note`, `edit_card`, `edit_key`, `edit_totp`,
|
||||
`edit_identity`, `edit_document_message`); and `push_history`.
|
||||
|
||||
- **`src/prompt.rs`** — interactive prompt primitives shared across commands:
|
||||
`prompt`, `prompt_optional`, `prompt_keep`, `prompt_keep_opt`,
|
||||
`prompt_yesno`, `prompt_secret`. `prompt_secret` honours
|
||||
`RELICARIO_TEST_ITEM_SECRET` before falling back to `rpassword`.
|
||||
`prompt_keep`, `prompt_keep_opt`, `prompt_yesno`, `prompt_secret`, and the
|
||||
flag-or-prompt pair `prompt_or_flag` / `prompt_or_flag_optional`.
|
||||
`prompt_secret` honours `RELICARIO_TEST_ITEM_SECRET` before falling back to
|
||||
`rpassword`.
|
||||
|
||||
- **`src/parse.rs`** — pure parsers for CLI-typed inputs (e.g. MonthYear
|
||||
expiries, TOTP `otpauth://` URIs, comma-separated tag lists). No I/O.
|
||||
@@ -167,7 +180,7 @@ in code; cite the line if you change it.
|
||||
works without any setup.
|
||||
|
||||
- **Item IDs are minted by core.** The CLI never constructs an `ItemId`
|
||||
directly; `Item::new` (called inside every `build_*_item`) does it via
|
||||
directly; `Item::new` (called inside every `item_build::build_*`) does it via
|
||||
`relicario-core::ids::new_item_id`. `ItemId`s are 8-char hex.
|
||||
|
||||
- **Manifest is always saved last.** Within a single command, the order is:
|
||||
@@ -237,15 +250,23 @@ in code; cite the line if you change it.
|
||||
### Item add (`cmd_add`, `main.rs:419-456`)
|
||||
|
||||
1. Unlock the vault and load the manifest.
|
||||
2. Match on the `AddKind` variant and dispatch to the matching
|
||||
`build_<type>_item` helper (`main.rs:423-438`). Seven variants → seven
|
||||
builders; only `build_document_item` takes `&UnlockedVault` because it
|
||||
needs `attachment_caps` and writes the encrypted blob alongside the item.
|
||||
3. The builder returns a fully-populated `Item` (with title, group, tags,
|
||||
2. Match on the `AddKind` variant: resolve `title` and non-secret fields
|
||||
(username, URL, holder, expiry, etc.) via `prompt_or_flag` /
|
||||
`prompt_or_flag_optional`, then delegate to the matching `build_*` builder
|
||||
in `commands/item_build.rs`. Seven variants → seven builders; only
|
||||
`build_document` takes `&UnlockedVault` because it needs `attachment_caps`
|
||||
and writes the encrypted blob alongside the item.
|
||||
3. Single-line secrets (Login password, Card number/CVV/PIN, TOTP secret)
|
||||
accept a `--*-stdin` flag that reads one line from stdin instead of
|
||||
prompting; multiline secrets (SecureNote body, Key material) always read
|
||||
stdin to EOF — `--body-stdin` / `--material-stdin` suppress the interactive
|
||||
Ctrl-D hint. Secret-resolution rule: `commands/item_build.rs`
|
||||
`resolve_secret_line` / `resolve_secret_multiline`.
|
||||
4. The builder returns a fully-populated `Item` (with title, group, tags,
|
||||
favorite-flag, primary attachment if any).
|
||||
4. Common wrap-up: `vault.save_item(&item)`, `manifest.upsert(&item)`,
|
||||
5. Common wrap-up: `vault.save_item(&item)`, `manifest.upsert(&item)`,
|
||||
`vault.save_manifest(&manifest)`.
|
||||
5. Build the path list — `items/<id>.enc`, `manifest.enc`, plus one
|
||||
6. Build the path list — `items/<id>.enc`, `manifest.enc`, plus one
|
||||
`attachments/<id>/<aid>.enc` per attachment — and call `commit_paths`
|
||||
with message `add: <title> (<id>)` (`main.rs:444-452`).
|
||||
|
||||
@@ -578,11 +599,12 @@ applies to `relicario-core` unit tests, not these CLI integration tests.
|
||||
instead. Non-primary attachments on a Document (e.g., a scanned
|
||||
contract with an addendum) detach normally.
|
||||
|
||||
- **Per-type `build_*_item` / `edit_*` helpers exist by design after the
|
||||
`3f0f5b1` refactor.** Before the refactor, `cmd_add` and `cmd_edit`
|
||||
carried 217-line `match` arms. The split-out functions are easier to
|
||||
read, easier to test individually (the existing integration tests still
|
||||
drive them through the same paths), and easier to grow when a new
|
||||
- **Per-type `build_*` / `edit_*` helpers exist by design** (extracted in the
|
||||
`3f0f5b1` refactor, then centralized in `item_build.rs` for v0.8.1 so the
|
||||
personal and org surfaces share one set). Before the extraction, `cmd_add`
|
||||
and `cmd_edit` carried 217-line `match` arms. The split-out functions are
|
||||
easier to read, easier to test individually (the existing integration tests
|
||||
still drive them through the same paths), and easier to grow when a new
|
||||
`ItemCore` variant lands. Keep this shape — don't fold them back.
|
||||
|
||||
- **Why the CLI shells out to `git`, not libgit2 / gitoxide.** Three
|
||||
|
||||
@@ -1,37 +1,76 @@
|
||||
//! `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).
|
||||
//! `cmd_add` resolves `title` / non-secret prompts, then delegates to the
|
||||
//! shared builders in `commands/item_build.rs`. Group / tags / favorite are
|
||||
//! set AFTER the build so the builders stay portable to the org vault.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use anyhow::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};
|
||||
use crate::commands::item_build as ib;
|
||||
use crate::prompt::{prompt_or_flag, prompt_or_flag_optional};
|
||||
|
||||
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)?,
|
||||
AddKind::Login { title, username, url, password_prompt, password, password_stdin, group, tags, favorite, totp_qr } => {
|
||||
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 mut item = ib::build_login(title, username, url, password, password_stdin, password_prompt, totp_qr)?;
|
||||
item.group = group; item.tags = tags; item.favorite = favorite;
|
||||
item
|
||||
}
|
||||
AddKind::SecureNote { title, body_stdin, group, tags } => {
|
||||
// Per the v0.8.1 spec's unified secret model, a note body is a
|
||||
// multiline secret that always reads stdin to EOF. `body_stdin=false`
|
||||
// means "print the Ctrl-D hint" (interactive default); `true` suppresses
|
||||
// the hint for non-interactive use.
|
||||
// Secret-resolution rule: `commands/item_build.rs` `resolve_secret_multiline`.
|
||||
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
|
||||
let mut item = ib::build_secure_note(title, None, body_stdin)?;
|
||||
item.group = group; item.tags = tags;
|
||||
item
|
||||
}
|
||||
AddKind::Identity { title, full_name, email, phone, date_of_birth, group, tags } => {
|
||||
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
|
||||
let mut item = ib::build_identity(title, full_name, email, phone, date_of_birth)?;
|
||||
item.group = group; item.tags = tags;
|
||||
item
|
||||
}
|
||||
AddKind::Card { title, holder, expiry, kind, number_stdin, cvv_stdin, pin_stdin, group, tags } => {
|
||||
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
|
||||
let mut item = ib::build_card(title, holder, expiry, &kind, number_stdin, cvv_stdin, pin_stdin)?;
|
||||
item.group = group; item.tags = tags;
|
||||
item
|
||||
}
|
||||
AddKind::Key { title, label, algorithm, material_stdin, group, tags } => {
|
||||
// public_key is None for the personal vault: the legacy `prompt_optional`
|
||||
// for it was unreachable (stdin already at EOF after the key-material read).
|
||||
// Org `add key` (Dev-B) supplies it via --public-key.
|
||||
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
|
||||
let mut item = ib::build_key(title, label, algorithm, None, material_stdin)?;
|
||||
item.group = group; item.tags = tags;
|
||||
item
|
||||
}
|
||||
AddKind::Document { title, file, group, tags } => {
|
||||
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
|
||||
let caps = vault.load_settings()?.attachment_caps;
|
||||
let (mut item, enc) = ib::build_document(title, file, vault.key(), caps.per_attachment_max_bytes)?;
|
||||
item.group = group; item.tags = tags;
|
||||
let att_dir = vault.root().join("attachments").join(item.id.as_str());
|
||||
std::fs::create_dir_all(&att_dir)?;
|
||||
std::fs::write(att_dir.join(format!("{}.enc", enc.id.as_str())), &enc.bytes)?;
|
||||
item
|
||||
}
|
||||
AddKind::Totp { title, issuer, label, secret, secret_stdin, period, digits, algorithm, group, tags } => {
|
||||
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
|
||||
let mut item = ib::build_totp(title, issuer, label, secret, secret_stdin, period, digits, &algorithm)?;
|
||||
item.group = group; item.tags = tags;
|
||||
item
|
||||
}
|
||||
};
|
||||
|
||||
vault.save_item(&item)?;
|
||||
@@ -51,263 +90,3 @@ pub fn cmd_add(kind: AddKind) -> Result<()> {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -2,10 +2,9 @@
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::parse::base32_decode_lenient;
|
||||
use crate::prompt::{prompt_keep, prompt_keep_opt, prompt_secret, prompt_yesno};
|
||||
use crate::prompt::{prompt_keep, prompt_keep_opt};
|
||||
|
||||
pub fn cmd_edit(query: String, totp_qr: Option<PathBuf>) -> Result<()> {
|
||||
use relicario_core::time::now_unix;
|
||||
@@ -29,13 +28,13 @@ pub fn cmd_edit(query: String, totp_qr: Option<PathBuf>) -> Result<()> {
|
||||
|
||||
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)?,
|
||||
ItemCore::Login(l) => crate::commands::item_build::edit_login(l, history, totp_qr)?,
|
||||
ItemCore::SecureNote(n) => crate::commands::item_build::edit_secure_note(n, history)?,
|
||||
ItemCore::Identity(i) => crate::commands::item_build::edit_identity(i)?,
|
||||
ItemCore::Card(c) => crate::commands::item_build::edit_card(c, history)?,
|
||||
ItemCore::Key(k) => crate::commands::item_build::edit_key(k, history)?,
|
||||
ItemCore::Document(_) => crate::commands::item_build::edit_document_message(),
|
||||
ItemCore::Totp(t) => crate::commands::item_build::edit_totp(t, history)?,
|
||||
}
|
||||
|
||||
item.modified = now_unix();
|
||||
@@ -47,125 +46,3 @@ pub fn cmd_edit(query: String, totp_qr: Option<PathBuf>) -> Result<()> {
|
||||
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(),
|
||||
});
|
||||
}
|
||||
|
||||
318
crates/relicario-cli/src/commands/item_build.rs
Normal file
318
crates/relicario-cli/src/commands/item_build.rs
Normal file
@@ -0,0 +1,318 @@
|
||||
//! Shared per-type item construction + interactive editing for both the
|
||||
//! personal vault (`commands/add.rs`, `commands/edit.rs`) and the org vault
|
||||
//! (`commands/org.rs`). Centralizing it keeps the two surfaces from drifting.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
use relicario_core::item::FieldHistoryEntry;
|
||||
use relicario_core::item_types::{CardKind, TotpAlgorithm};
|
||||
use relicario_core::time::now_unix;
|
||||
use relicario_core::{EncryptedAttachment, FieldId, Item, ItemCore};
|
||||
|
||||
use crate::parse::base32_decode_lenient;
|
||||
use crate::prompt::{prompt_keep_opt, prompt_secret, prompt_yesno};
|
||||
|
||||
pub(crate) type FieldHistory = HashMap<FieldId, Vec<FieldHistoryEntry>>;
|
||||
|
||||
/// Resolve a single-line secret: from stdin when `from_stdin`, else an
|
||||
/// interactive masked prompt (which honours `RELICARIO_TEST_ITEM_SECRET`).
|
||||
pub(crate) fn resolve_secret_line(from_stdin: bool, label: &str) -> Result<String> {
|
||||
if from_stdin {
|
||||
let mut s = String::new();
|
||||
std::io::stdin().read_line(&mut s)?;
|
||||
Ok(s.trim_end_matches(['\n', '\r']).to_string())
|
||||
} else {
|
||||
crate::prompt::prompt_secret(&format!("{label}: "))
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a multiline secret (key material, note body). Both paths read stdin
|
||||
/// to EOF; the interactive path first prints `hint` to stderr.
|
||||
pub(crate) fn resolve_secret_multiline(from_stdin: bool, hint: &str) -> Result<String> {
|
||||
if !from_stdin {
|
||||
eprintln!("{hint}");
|
||||
}
|
||||
let mut s = String::new();
|
||||
std::io::Read::read_to_string(&mut std::io::stdin(), &mut s)?;
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
pub(crate) fn parse_card_kind(s: &str) -> Result<CardKind> {
|
||||
Ok(match s {
|
||||
"credit" => CardKind::Credit,
|
||||
"debit" => CardKind::Debit,
|
||||
"gift" => CardKind::Gift,
|
||||
"loyalty" => CardKind::Loyalty,
|
||||
"other" => CardKind::Other,
|
||||
other => anyhow::bail!("unknown card kind: {other}"),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn parse_totp_algorithm(s: &str) -> Result<TotpAlgorithm> {
|
||||
Ok(match s.to_ascii_lowercase().as_str() {
|
||||
"sha1" => TotpAlgorithm::Sha1,
|
||||
"sha256" => TotpAlgorithm::Sha256,
|
||||
"sha512" => TotpAlgorithm::Sha512,
|
||||
other => anyhow::bail!("unknown algorithm: {other}"),
|
||||
})
|
||||
}
|
||||
|
||||
// --- Per-type interactive edit helpers (moved from commands/edit.rs). Each
|
||||
// mutates its core slice in place; history-tracked variants take the
|
||||
// item's field_history map so they can record the prior value.
|
||||
|
||||
pub(crate) fn edit_login(
|
||||
l: &mut relicario_core::item_types::LoginCore,
|
||||
history: &mut FieldHistory,
|
||||
totp_qr: Option<PathBuf>,
|
||||
) -> Result<()> {
|
||||
use relicario_core::item_types::{TotpConfig, TotpKind};
|
||||
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(())
|
||||
}
|
||||
|
||||
pub(crate) fn edit_secure_note(n: &mut relicario_core::item_types::SecureNoteCore, history: &mut FieldHistory) -> Result<()> {
|
||||
if prompt_yesno("Edit body?")? {
|
||||
let old = n.body.clone();
|
||||
let s = resolve_secret_multiline(false, "Enter new body; end with Ctrl-D:")?;
|
||||
n.body = Zeroizing::new(s);
|
||||
push_history(history, "secure_note_body", Zeroizing::new(old.as_str().to_string()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) 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(())
|
||||
}
|
||||
|
||||
pub(crate) fn edit_card(c: &mut relicario_core::item_types::CardCore, history: &mut FieldHistory) -> Result<()> {
|
||||
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(())
|
||||
}
|
||||
|
||||
pub(crate) fn edit_key(k: &mut relicario_core::item_types::KeyCore, history: &mut FieldHistory) -> Result<()> {
|
||||
if prompt_yesno("Replace key material?")? {
|
||||
let s = resolve_secret_multiline(false, "Paste new key material; end with Ctrl-D:")?;
|
||||
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(())
|
||||
}
|
||||
|
||||
pub(crate) fn edit_document_message() {
|
||||
eprintln!("Document items: use `relicario attach` / `relicario extract` instead.");
|
||||
}
|
||||
|
||||
pub(crate) fn edit_totp(t: &mut relicario_core::item_types::TotpCore, history: &mut FieldHistory) -> Result<()> {
|
||||
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(())
|
||||
}
|
||||
|
||||
pub(crate) fn build_login(
|
||||
title: String, username: Option<String>, url: Option<String>,
|
||||
password: Option<String>, password_stdin: bool, password_prompt: bool,
|
||||
totp_qr: Option<PathBuf>,
|
||||
) -> Result<Item> {
|
||||
use relicario_core::item_types::{LoginCore, TotpConfig, TotpKind};
|
||||
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_stdin {
|
||||
Some(Zeroizing::new(resolve_secret_line(password_stdin, "Password")?))
|
||||
} else if password_prompt {
|
||||
Some(Zeroizing::new(crate::prompt::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 };
|
||||
Ok(Item::new(title, ItemCore::Login(LoginCore { username, password, url: parsed_url, totp })))
|
||||
}
|
||||
|
||||
pub(crate) fn build_secure_note(title: String, body: Option<String>, body_stdin: bool) -> Result<Item> {
|
||||
use relicario_core::item_types::SecureNoteCore;
|
||||
let body = match body {
|
||||
Some(b) => b,
|
||||
None => resolve_secret_multiline(body_stdin, "Enter note body; end with Ctrl-D on a blank line:")?,
|
||||
};
|
||||
Ok(Item::new(title, ItemCore::SecureNote(SecureNoteCore { body: Zeroizing::new(body) })))
|
||||
}
|
||||
|
||||
pub(crate) fn build_identity(
|
||||
title: String, full_name: Option<String>, email: Option<String>,
|
||||
phone: Option<String>, date_of_birth: Option<String>,
|
||||
) -> Result<Item> {
|
||||
use relicario_core::item_types::IdentityCore;
|
||||
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,
|
||||
};
|
||||
Ok(Item::new(title, ItemCore::Identity(IdentityCore {
|
||||
full_name, address: None, phone, email, date_of_birth: dob,
|
||||
})))
|
||||
}
|
||||
|
||||
pub(crate) fn build_card(
|
||||
title: String, holder: Option<String>, expiry: Option<String>, kind: &str,
|
||||
number_stdin: bool, cvv_stdin: bool, pin_stdin: bool,
|
||||
) -> Result<Item> {
|
||||
use relicario_core::item_types::CardCore;
|
||||
let number = Zeroizing::new(resolve_secret_line(number_stdin, "Card number")?);
|
||||
let cvv = resolve_secret_line(cvv_stdin, "CVV (blank to skip)")?;
|
||||
let cvv = if cvv.is_empty() { None } else { Some(Zeroizing::new(cvv)) };
|
||||
let pin = resolve_secret_line(pin_stdin, "PIN (blank to skip)")?;
|
||||
let pin = if pin.is_empty() { None } else { Some(Zeroizing::new(pin)) };
|
||||
let parsed_expiry = match expiry { Some(s) => Some(crate::parse::parse_month_year(&s)?), None => None };
|
||||
Ok(Item::new(title, ItemCore::Card(CardCore {
|
||||
number: Some(number), holder, expiry: parsed_expiry, cvv, pin, kind: parse_card_kind(kind)?,
|
||||
})))
|
||||
}
|
||||
|
||||
pub(crate) fn build_key(
|
||||
title: String, label: Option<String>, algorithm: Option<String>,
|
||||
public_key: Option<String>, material_stdin: bool,
|
||||
) -> Result<Item> {
|
||||
use relicario_core::item_types::KeyCore;
|
||||
let key_material = resolve_secret_multiline(material_stdin, "Paste key material; end with Ctrl-D on a blank line:")?;
|
||||
if key_material.trim().is_empty() { anyhow::bail!("key material required"); }
|
||||
Ok(Item::new(title, ItemCore::Key(KeyCore {
|
||||
key_material: Zeroizing::new(key_material), label, public_key, algorithm,
|
||||
})))
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn build_totp(
|
||||
title: String, issuer: Option<String>, label: Option<String>,
|
||||
secret: Option<String>, secret_stdin: bool, period: u32, digits: u8, algorithm: &str,
|
||||
) -> Result<Item> {
|
||||
use relicario_core::item_types::{TotpConfig, TotpCore, TotpKind};
|
||||
let secret_b32 = match secret {
|
||||
Some(s) => s,
|
||||
None => resolve_secret_line(secret_stdin, "TOTP secret (base32)")?,
|
||||
};
|
||||
let secret_bytes = base32_decode_lenient(&secret_b32)?;
|
||||
Ok(Item::new(title, ItemCore::Totp(TotpCore {
|
||||
config: TotpConfig {
|
||||
secret: Zeroizing::new(secret_bytes), algorithm: parse_totp_algorithm(algorithm)?,
|
||||
digits, period_seconds: period, kind: TotpKind::Totp,
|
||||
},
|
||||
issuer, label,
|
||||
})))
|
||||
}
|
||||
|
||||
pub(crate) fn build_document(
|
||||
title: String, file: PathBuf, key: &Zeroizing<[u8; 32]>, max_bytes: u64,
|
||||
) -> Result<(Item, EncryptedAttachment)> {
|
||||
use relicario_core::item_types::DocumentCore;
|
||||
use relicario_core::{encrypt_attachment, AttachmentRef};
|
||||
let bytes = std::fs::read(&file).with_context(|| format!("failed to read {}", file.display()))?;
|
||||
let enc = encrypt_attachment(&bytes, key, 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 = crate::parse::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.attachments.push(AttachmentRef {
|
||||
id: primary_attachment, filename, mime_type, size: bytes.len() as u64, created: item.created,
|
||||
});
|
||||
Ok((item, enc))
|
||||
}
|
||||
|
||||
pub(crate) fn push_history(
|
||||
history: &mut FieldHistory,
|
||||
synthetic_key: &str,
|
||||
old_value: zeroize::Zeroizing<String>,
|
||||
) {
|
||||
// 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(),
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use relicario_core::item_types::{CardKind, TotpAlgorithm};
|
||||
|
||||
#[test]
|
||||
fn card_kind_parses_known_values() {
|
||||
assert_eq!(parse_card_kind("credit").unwrap(), CardKind::Credit);
|
||||
assert_eq!(parse_card_kind("loyalty").unwrap(), CardKind::Loyalty);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn card_kind_rejects_unknown() {
|
||||
assert!(parse_card_kind("platinum").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn totp_algorithm_is_case_insensitive() {
|
||||
assert_eq!(parse_totp_algorithm("SHA256").unwrap(), TotpAlgorithm::Sha256);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn totp_algorithm_rejects_unknown() {
|
||||
assert!(parse_totp_algorithm("md5").is_err());
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ pub mod edit;
|
||||
pub mod generate;
|
||||
pub mod get;
|
||||
pub mod import;
|
||||
pub mod item_build;
|
||||
pub mod org;
|
||||
pub mod init;
|
||||
pub mod list;
|
||||
|
||||
@@ -227,6 +227,8 @@ pub(crate) enum AddKind {
|
||||
/// Prompt for password (vs reading from stdin or --password).
|
||||
#[arg(long)] password_prompt: bool,
|
||||
#[arg(long)] password: Option<String>,
|
||||
/// Read the password from stdin (one line) instead of prompting.
|
||||
#[arg(long)] password_stdin: bool,
|
||||
#[arg(long)] group: Option<String>,
|
||||
#[arg(long, value_delimiter = ',')] tags: Vec<String>,
|
||||
#[arg(long)] favorite: bool,
|
||||
@@ -235,7 +237,8 @@ pub(crate) enum AddKind {
|
||||
},
|
||||
SecureNote {
|
||||
#[arg(long)] title: Option<String>,
|
||||
#[arg(long)] body_prompt: bool,
|
||||
/// Read the note body from stdin (to EOF) instead of printing the Ctrl-D hint.
|
||||
#[arg(long)] body_stdin: bool,
|
||||
#[arg(long)] group: Option<String>,
|
||||
#[arg(long, value_delimiter = ',')] tags: Vec<String>,
|
||||
},
|
||||
@@ -253,6 +256,12 @@ pub(crate) enum AddKind {
|
||||
#[arg(long)] holder: Option<String>,
|
||||
#[arg(long)] expiry: Option<String>, // MM/YYYY
|
||||
#[arg(long, default_value = "credit")] kind: String,
|
||||
/// Read the card number from stdin (one line) instead of prompting.
|
||||
#[arg(long)] number_stdin: bool,
|
||||
/// Read the CVV from stdin (one line) instead of prompting.
|
||||
#[arg(long)] cvv_stdin: bool,
|
||||
/// Read the PIN from stdin (one line) instead of prompting.
|
||||
#[arg(long)] pin_stdin: bool,
|
||||
#[arg(long)] group: Option<String>,
|
||||
#[arg(long, value_delimiter = ',')] tags: Vec<String>,
|
||||
},
|
||||
@@ -260,6 +269,8 @@ pub(crate) enum AddKind {
|
||||
#[arg(long)] title: Option<String>,
|
||||
#[arg(long)] label: Option<String>,
|
||||
#[arg(long)] algorithm: Option<String>,
|
||||
/// Read the key material from stdin (to EOF) instead of printing the Ctrl-D hint.
|
||||
#[arg(long)] material_stdin: bool,
|
||||
#[arg(long)] group: Option<String>,
|
||||
#[arg(long, value_delimiter = ',')] tags: Vec<String>,
|
||||
},
|
||||
@@ -274,6 +285,8 @@ pub(crate) enum AddKind {
|
||||
#[arg(long)] issuer: Option<String>,
|
||||
#[arg(long)] label: Option<String>,
|
||||
#[arg(long)] secret: Option<String>, // base32
|
||||
/// Read the TOTP secret from stdin (one line) instead of prompting.
|
||||
#[arg(long)] secret_stdin: bool,
|
||||
#[arg(long, default_value = "30")] period: u32,
|
||||
#[arg(long, default_value = "6")] digits: u8,
|
||||
#[arg(long, default_value = "sha1")] algorithm: String,
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
//! 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
|
||||
//! `prompt_secret` reads a masked secret from the TTY (honouring
|
||||
//! `RELICARIO_TEST_ITEM_SECRET` so integration tests without a TTY can inject
|
||||
//! secrets); 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.
|
||||
//! `prompt_or_flag` and `prompt_or_flag_optional` thread a CLI-flag value
|
||||
//! through the same path so command handlers can use one call site whether
|
||||
//! the value came from the command line or from an interactive prompt.
|
||||
//! at a blank prompt. `prompt_or_flag` and `prompt_or_flag_optional` thread a
|
||||
//! CLI-flag value through the same path so command handlers can use one call
|
||||
//! site whether the value came from the command line or an interactive prompt.
|
||||
|
||||
use anyhow::Result;
|
||||
use std::io::BufRead;
|
||||
@@ -41,18 +40,6 @@ fn read_optional_line<R: BufRead>(reader: &mut R, label: &str) -> Result<Option<
|
||||
Ok(if trimmed.is_empty() { None } else { Some(trimmed) })
|
||||
}
|
||||
|
||||
pub(crate) fn prompt(label: &str) -> Result<String> {
|
||||
let stdin = std::io::stdin();
|
||||
let mut reader = std::io::BufReader::new(stdin.lock());
|
||||
read_required_line(&mut reader, label)
|
||||
}
|
||||
|
||||
pub(crate) fn prompt_optional(label: &str) -> Result<Option<String>> {
|
||||
let stdin = std::io::stdin();
|
||||
let mut reader = std::io::BufReader::new(stdin.lock());
|
||||
read_optional_line(&mut reader, label)
|
||||
}
|
||||
|
||||
pub(crate) fn prompt_keep(label: &str, current: &str) -> Result<Option<String>> {
|
||||
eprint!("{label} [{current}]: ");
|
||||
std::io::Write::flush(&mut std::io::stderr())?;
|
||||
|
||||
@@ -201,3 +201,20 @@ fn generate_random_and_bip39() {
|
||||
let phrase = String::from_utf8(out.stdout).unwrap();
|
||||
assert_eq!(phrase.trim().split(' ').count(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_card_via_stdin_flags_is_non_interactive() {
|
||||
let v = TestVault::init();
|
||||
let out = v.run_with_input(
|
||||
&["add", "card", "--title", "Visa", "--kind", "credit",
|
||||
"--number-stdin", "--cvv-stdin", "--pin-stdin"],
|
||||
&["4111111111111111", "123", "4321"],
|
||||
);
|
||||
assert!(out.status.success(), "add card via stdin failed: {}", String::from_utf8_lossy(&out.stderr));
|
||||
|
||||
let got = v.run(&["get", "Visa"]);
|
||||
assert!(got.status.success(), "get Visa failed: {}", String::from_utf8_lossy(&got.stderr));
|
||||
let stdout = String::from_utf8_lossy(&got.stdout);
|
||||
assert!(stdout.contains("********"), "card number should be masked without --show: {stdout}");
|
||||
assert!(!stdout.contains("4111111111111111"), "card number leaked without --show: {stdout}");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user