Compare commits

...

13 Commits

Author SHA1 Message Date
adlee-was-taken
e76d7167d6 test(cli/org): grant enforcement + body/secret-stdin + key-edit coverage
Closes the minor coverage gaps from the final adversarial review:
- org add card/key/totp reject ungranted + unknown collections (pins the
  grant gate on the new write paths, which runs before any secret prompt)
- secure-note --body-stdin masks body; totp --secret-stdin round-trips
  (completes the --*-stdin matrix for the org surface)
- key-material edit accept-branch round-trip, verified via get --show
2026-06-20 20:58:26 -04:00
adlee-was-taken
04ad98973a test(cli/org): adapt grant-denial edit case to interactive org edit
B3 dropped the flat --username/--url/... flags from `org edit`, so the
ungranted-member denial test must drive the bare interactive form. The
ungranted member is now rejected at manifest lookup (filter_for_member +
resolve_org_query) before any prompt is read.
2026-06-20 20:49:12 -04:00
adlee-was-taken
290bc4e2d0 feat(cli/org): interactive per-type org edit via shared edit helpers 2026-06-20 20:43:03 -04:00
adlee-was-taken
82feb49ab4 feat(cli/org): org add parity for Card/Key/Totp via shared builders 2026-06-20 18:31:29 -04:00
adlee-was-taken
07862b8d44 test(cli/org): failing Card/Key/Totp org add round-trips (B4, pre-A-integration)
Adds run_stdin + create_collection_and_grant fixture helpers and three
acceptance tests for org add card/key/totp. Red until B1/B2 wire the
subcommands (currently: unrecognized subcommand). Asserts org get masks
card number + key material without --show. Edit round-trips land with B3.
2026-06-20 18:26:11 -04:00
adlee-was-taken
b09e0ce036 merge: feature/v0.8.1-dev-a-foundation (v0.8.1 Dev-A) — shared item_build module + personal add/edit refactor + --*-stdin flags 2026-06-20 18:24:04 -04:00
adlee-was-taken
d8b23d421e refactor(cli): tidy item_build edit helpers (simplify pass)
- edit_secure_note / edit_key now call the module's resolve_secret_multiline
  instead of open-coding the eprintln-hint + read-to-EOF pattern (the helper
  exists precisely to centralize this; build_secure_note/build_key already use it).
- drop redundant fn-local imports: `use zeroize::Zeroizing;` from the five edit_*
  helpers and the re-imported `TotpAlgorithm` from edit_login/build_login
  (all covered by module-level imports; leftover from the verbatim A2/A3 move).
- build_login passes the password_stdin flag through to resolve_secret_line for
  consistency with build_card/build_totp (behavior identical — that branch is
  only reached when password_stdin is true).
- restore #[allow(clippy::too_many_arguments)] on build_totp (8 args; the old
  build_totp_item carried the same allow — signature is frozen for B/C).
2026-06-20 18:14:10 -04:00
adlee-was-taken
6eb1275710 feat(cli): --*-stdin secret flags for personal add (non-interactive secrets) 2026-06-20 17:56:45 -04:00
adlee-was-taken
751e4e9bb1 chore(cli): remove now-dead prompt/prompt_optional helpers
A3 routed personal `add` through the shared item_build builders, which use
prompt_secret / resolve_secret_*; the generic single-line prompt() and
prompt_optional() lost their last callers. read_required_line /
read_optional_line stay (used by prompt_or_flag*).
2026-06-20 17:40:52 -04:00
adlee-was-taken
db4e05a193 merge: feature/v0.8.1-dev-d-server-hook (v0.8.1 Dev-D) — grant-scope org attachment write paths in pre-receive hook 2026-06-20 17:38:27 -04:00
adlee-was-taken
65e23cfddc refactor(cli): personal add delegates to shared item_build builders 2026-06-20 17:35:18 -04:00
adlee-was-taken
b83643ee0a refactor(cli): move per-type edit helpers into shared item_build module 2026-06-20 17:27:05 -04:00
adlee-was-taken
154b984725 feat(cli): shared item_build module — secret resolution + type parsers 2026-06-20 17:21:43 -04:00
11 changed files with 860 additions and 553 deletions

View File

@@ -37,15 +37,28 @@ under `src/commands/`. Each source file has one job.
`cmd_history`), `edit`, `trash` (rm / restore / purge / trash empty), `cmd_history`), `edit`, `trash` (rm / restore / purge / trash empty),
`backup` (export / restore), `import` (lastpass), `attach` (attach / `backup` (export / restore), `import` (lastpass), `attach` (attach /
attachments / extract / detach), `generate`, `settings`, `sync`, `status`, attachments / extract / detach), `generate`, `settings`, `sync`, `status`,
`rate`, `device`, `recovery_qr`. `add` and `edit` each fan out internally to `rate`, `device`, `recovery_qr`. `add` and `edit` resolve their non-secret
per-`ItemCore` helpers (`build_<type>_item`, `edit_<type>`) so each fields then delegate to the shared `item_build` module's per-`ItemCore`
builder/editor reads top-to-bottom and can be tested through the same `build_*` / `edit_*` helpers (see the next bullet), so each builder/editor
integration paths. 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: - **`src/prompt.rs`** — interactive prompt primitives shared across commands:
`prompt`, `prompt_optional`, `prompt_keep`, `prompt_keep_opt`, `prompt_keep`, `prompt_keep_opt`, `prompt_yesno`, `prompt_secret`, and the
`prompt_yesno`, `prompt_secret`. `prompt_secret` honours flag-or-prompt pair `prompt_or_flag` / `prompt_or_flag_optional`.
`RELICARIO_TEST_ITEM_SECRET` before falling back to `rpassword`. `prompt_secret` honours `RELICARIO_TEST_ITEM_SECRET` before falling back to
`rpassword`.
- **`src/parse.rs`** — pure parsers for CLI-typed inputs (e.g. MonthYear - **`src/parse.rs`** — pure parsers for CLI-typed inputs (e.g. MonthYear
expiries, TOTP `otpauth://` URIs, comma-separated tag lists). No I/O. 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. works without any setup.
- **Item IDs are minted by core.** The CLI never constructs an `ItemId` - **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. `relicario-core::ids::new_item_id`. `ItemId`s are 8-char hex.
- **Manifest is always saved last.** Within a single command, the order is: - **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`) ### Item add (`cmd_add`, `main.rs:419-456`)
1. Unlock the vault and load the manifest. 1. Unlock the vault and load the manifest.
2. Match on the `AddKind` variant and dispatch to the matching 2. Match on the `AddKind` variant: resolve `title` and non-secret fields
`build_<type>_item` helper (`main.rs:423-438`). Seven variants → seven (username, URL, holder, expiry, etc.) via `prompt_or_flag` /
builders; only `build_document_item` takes `&UnlockedVault` because it `prompt_or_flag_optional`, then delegate to the matching `build_*` builder
needs `attachment_caps` and writes the encrypted blob alongside the item. in `commands/item_build.rs`. Seven variants → seven builders; only
3. The builder returns a fully-populated `Item` (with title, group, tags, `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). 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)`. `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` `attachments/<id>/<aid>.enc` per attachment — and call `commit_paths`
with message `add: <title> (<id>)` (`main.rs:444-452`). 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 instead. Non-primary attachments on a Document (e.g., a scanned
contract with an addendum) detach normally. contract with an addendum) detach normally.
- **Per-type `build_*_item` / `edit_*` helpers exist by design after the - **Per-type `build_*` / `edit_*` helpers exist by design** (extracted in the
`3f0f5b1` refactor.** Before the refactor, `cmd_add` and `cmd_edit` `3f0f5b1` refactor, then centralized in `item_build.rs` for v0.8.1 so the
carried 217-line `match` arms. The split-out functions are easier to personal and org surfaces share one set). Before the extraction, `cmd_add`
read, easier to test individually (the existing integration tests still and `cmd_edit` carried 217-line `match` arms. The split-out functions are
drive them through the same paths), and easier to grow when a new 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. `ItemCore` variant lands. Keep this shape — don't fold them back.
- **Why the CLI shells out to `git`, not libgit2 / gitoxide.** Three - **Why the CLI shells out to `git`, not libgit2 / gitoxide.** Three

View File

@@ -1,37 +1,76 @@
//! `relicario add <kind>` — create a new item of the given type. //! `relicario add <kind>` — create a new item of the given type.
//! //!
//! `cmd_add` does the common save / manifest upsert / commit dance. The seven //! `cmd_add` resolves `title` / non-secret prompts, then delegates to the
//! per-type `build_*_item` helpers each return a fully-populated `Item`. The //! shared builders in `commands/item_build.rs`. Group / tags / favorite are
//! `Document` builder is the only one that needs the unlocked vault (for the //! set AFTER the build so the builders stay portable to the org vault.
//! attachment-cap settings + writing the encrypted blob alongside the item).
use std::path::PathBuf; use anyhow::Result;
use anyhow::{Context, Result};
use crate::AddKind; use crate::AddKind;
use crate::parse::{base32_decode_lenient, guess_mime, parse_month_year}; use crate::commands::item_build as ib;
use crate::prompt::{prompt, prompt_optional, prompt_or_flag, prompt_or_flag_optional, prompt_secret}; use crate::prompt::{prompt_or_flag, prompt_or_flag_optional};
pub fn cmd_add(kind: AddKind) -> Result<()> { pub fn cmd_add(kind: AddKind) -> Result<()> {
let vault = crate::session::UnlockedVault::unlock_interactive()?; let vault = crate::session::UnlockedVault::unlock_interactive()?;
let mut manifest = vault.load_manifest()?; let mut manifest = vault.load_manifest()?;
let item = match kind { let item = match kind {
AddKind::Login { title, username, url, password_prompt, password, group, tags, favorite, totp_qr } => AddKind::Login { title, username, url, password_prompt, password, password_stdin, group, tags, favorite, totp_qr } => {
build_login_item(title, username, url, password_prompt, password, group, tags, favorite, totp_qr)?, let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
AddKind::SecureNote { title, body_prompt, group, tags } => let username = prompt_or_flag_optional(username, "Username", |s| Ok(s.to_string()))?;
build_secure_note_item(title, body_prompt, group, tags)?, let url = prompt_or_flag_optional(url, "URL", |s| Ok(s.to_string()))?;
AddKind::Identity { title, full_name, email, phone, date_of_birth, group, tags } => let mut item = ib::build_login(title, username, url, password, password_stdin, password_prompt, totp_qr)?;
build_identity_item(title, full_name, email, phone, date_of_birth, group, tags)?, item.group = group; item.tags = tags; item.favorite = favorite;
AddKind::Card { title, holder, expiry, kind, group, tags } => item
build_card_item(title, holder, expiry, kind, group, tags)?, }
AddKind::Key { title, label, algorithm, group, tags } => AddKind::SecureNote { title, body_stdin, group, tags } => {
build_key_item(title, label, algorithm, group, tags)?, // Per the v0.8.1 spec's unified secret model, a note body is a
AddKind::Document { title, file, group, tags } => // multiline secret that always reads stdin to EOF. `body_stdin=false`
build_document_item(&vault, title, file, group, tags)?, // means "print the Ctrl-D hint" (interactive default); `true` suppresses
AddKind::Totp { title, issuer, label, secret, period, digits, algorithm, group, tags } => // the hint for non-interactive use.
build_totp_item(title, issuer, label, secret, period, digits, algorithm, group, tags)?, // 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)?; vault.save_item(&item)?;
@@ -51,263 +90,3 @@ pub fn cmd_add(kind: AddKind) -> Result<()> {
eprintln!("Added: {} (id={})", item.title, item.id.as_str()); eprintln!("Added: {} (id={})", item.title, item.id.as_str());
Ok(()) 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)
}

View File

@@ -2,10 +2,9 @@
use std::path::PathBuf; 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};
use crate::prompt::{prompt_keep, prompt_keep_opt, prompt_secret, prompt_yesno};
pub fn cmd_edit(query: String, totp_qr: Option<PathBuf>) -> Result<()> { pub fn cmd_edit(query: String, totp_qr: Option<PathBuf>) -> Result<()> {
use relicario_core::time::now_unix; 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; let history = &mut item.field_history;
match &mut item.core { match &mut item.core {
ItemCore::Login(l) => edit_login(l, history, totp_qr)?, ItemCore::Login(l) => crate::commands::item_build::edit_login(l, history, totp_qr)?,
ItemCore::SecureNote(n) => edit_secure_note(n, history)?, ItemCore::SecureNote(n) => crate::commands::item_build::edit_secure_note(n, history)?,
ItemCore::Identity(i) => edit_identity(i)?, ItemCore::Identity(i) => crate::commands::item_build::edit_identity(i)?,
ItemCore::Card(c) => edit_card(c, history)?, ItemCore::Card(c) => crate::commands::item_build::edit_card(c, history)?,
ItemCore::Key(k) => edit_key(k, history)?, ItemCore::Key(k) => crate::commands::item_build::edit_key(k, history)?,
ItemCore::Document(_) => edit_document_message(), ItemCore::Document(_) => crate::commands::item_build::edit_document_message(),
ItemCore::Totp(t) => edit_totp(t, history)?, ItemCore::Totp(t) => crate::commands::item_build::edit_totp(t, history)?,
} }
item.modified = now_unix(); 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()); eprintln!("Updated {}", item.id.as_str());
Ok(()) Ok(())
} }
// --- Per-type edit handlers. Each mutates its core slice in place; the ones
// that touch history-tracked fields take the item's field_history map so
// they can record the prior value alongside the change.
type FieldHistory = std::collections::HashMap<
relicario_core::FieldId,
Vec<relicario_core::item::FieldHistoryEntry>,
>;
fn edit_login(
l: &mut relicario_core::item_types::LoginCore,
history: &mut FieldHistory,
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,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());
}
}

View File

@@ -14,6 +14,7 @@ pub mod edit;
pub mod generate; pub mod generate;
pub mod get; pub mod get;
pub mod import; pub mod import;
pub mod item_build;
pub mod org; pub mod org;
pub mod init; pub mod init;
pub mod list; pub mod list;

View File

@@ -6,12 +6,13 @@ use std::path::Path;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use relicario_core::{ use relicario_core::{
generate_org_key, wrap_org_key, generate_org_key, wrap_org_key,
CollectionDef, Item, ItemCore, MemberId, OrgCollections, OrgManifest, OrgMembers, OrgMeta, CollectionDef, Item, MemberId, OrgCollections, OrgManifest, OrgMembers, OrgMeta,
OrgRole, OrgMember, OrgRole, OrgMember,
encrypt_org_manifest, encrypt_org_manifest,
}; };
use crate::org_session::atomic_write; use crate::org_session::atomic_write;
use crate::commands::item_build as ib;
pub fn run_init(dir: &Path, name: &str) -> Result<()> { pub fn run_init(dir: &Path, name: &str) -> Result<()> {
// Create directory structure // Create directory structure
@@ -745,17 +746,20 @@ pub fn run_audit(
Ok(()) Ok(())
} }
/// Item kinds `org add` supports without interactive prompts. /// Item kinds `org add` supports. Secrets resolve via `--*-stdin` flags or an
/// interactive prompt inside the shared `item_build` builders.
pub enum OrgAddKind { pub enum OrgAddKind {
Login { Login {
title: String, title: String,
username: Option<String>, username: Option<String>,
url: Option<String>, url: Option<String>,
password: Option<String>, password: Option<String>,
password_stdin: bool,
}, },
SecureNote { SecureNote {
title: String, title: String,
body: String, body: Option<String>,
body_stdin: bool,
}, },
Identity { Identity {
title: String, title: String,
@@ -763,43 +767,56 @@ pub enum OrgAddKind {
email: Option<String>, email: Option<String>,
phone: Option<String>, phone: Option<String>,
}, },
Card {
title: String,
holder: Option<String>,
expiry: Option<String>,
kind: String,
number_stdin: bool,
cvv_stdin: bool,
pin_stdin: bool,
},
Key {
title: String,
label: Option<String>,
algorithm: Option<String>,
public_key: Option<String>,
material_stdin: bool,
},
Totp {
title: String,
issuer: Option<String>,
label: Option<String>,
secret: Option<String>,
secret_stdin: bool,
period: u32,
digits: u8,
algorithm: String,
},
// Document is added later by Dev-C.
} }
fn build_org_item(kind: OrgAddKind, tags: Vec<String>) -> Result<Item> { fn build_org_item(kind: OrgAddKind) -> Result<Item> {
use relicario_core::item_types::{IdentityCore, LoginCore, SecureNoteCore}; match kind {
use zeroize::Zeroizing; OrgAddKind::Login { title, username, url, password, password_stdin } => {
ib::build_login(title, username, url, password, password_stdin, false, None)
let mut item = match kind {
OrgAddKind::Login { title, username, url, password } => {
let parsed_url = match url {
Some(s) => Some(url::Url::parse(&s).with_context(|| format!("invalid URL: {s}"))?),
None => None,
};
let password = password.map(Zeroizing::new);
Item::new(title, ItemCore::Login(LoginCore {
username,
password,
url: parsed_url,
totp: None,
}))
} }
OrgAddKind::SecureNote { title, body } => { OrgAddKind::SecureNote { title, body, body_stdin } => {
Item::new(title, ItemCore::SecureNote(SecureNoteCore { ib::build_secure_note(title, body, body_stdin)
body: Zeroizing::new(body),
}))
} }
OrgAddKind::Identity { title, full_name, email, phone } => { OrgAddKind::Identity { title, full_name, email, phone } => {
Item::new(title, ItemCore::Identity(IdentityCore { ib::build_identity(title, full_name, email, phone, None)
full_name, }
address: None, OrgAddKind::Card { title, holder, expiry, kind, number_stdin, cvv_stdin, pin_stdin } => {
phone, ib::build_card(title, holder, expiry, &kind, number_stdin, cvv_stdin, pin_stdin)
email, }
date_of_birth: None, OrgAddKind::Key { title, label, algorithm, public_key, material_stdin } => {
})) ib::build_key(title, label, algorithm, public_key, material_stdin)
}
OrgAddKind::Totp { title, issuer, label, secret, secret_stdin, period, digits, algorithm } => {
ib::build_totp(title, issuer, label, secret, secret_stdin, period, digits, &algorithm)
}
} }
};
item.tags = tags;
Ok(item)
} }
pub fn run_add(dir: &Path, collection: &str, kind: OrgAddKind, tags: Vec<String>) -> Result<()> { pub fn run_add(dir: &Path, collection: &str, kind: OrgAddKind, tags: Vec<String>) -> Result<()> {
@@ -816,7 +833,8 @@ pub fn run_add(dir: &Path, collection: &str, kind: OrgAddKind, tags: Vec<String>
// …and the caller must hold a grant for it. // …and the caller must hold a grant for it.
UnlockedOrgVault::ensure_grant(&caller, collection)?; UnlockedOrgVault::ensure_grant(&caller, collection)?;
let item = build_org_item(kind, tags)?; let mut item = build_org_item(kind)?;
item.tags = tags;
let item_rel = vault.save_item(collection, &item)?; let item_rel = vault.save_item(collection, &item)?;
// Upsert the manifest entry (collection slug stored plaintext inside the // Upsert the manifest entry (collection slug stored plaintext inside the
@@ -973,21 +991,9 @@ fn resolve_org_query<'a>(
} }
} }
pub fn run_edit( pub fn run_edit(dir: &Path, query: &str, totp_qr: Option<std::path::PathBuf>) -> Result<()> {
dir: &Path,
query: &str,
title: Option<String>,
username: Option<String>,
url: Option<String>,
password: Option<String>,
body: Option<String>,
email: Option<String>,
phone: Option<String>,
full_name: Option<String>,
) -> Result<()> {
use relicario_core::time::now_unix; use relicario_core::time::now_unix;
use relicario_core::ItemCore; use relicario_core::ItemCore;
use zeroize::Zeroizing;
let vault = crate::org_session::open_org_vault(Some(dir))?; let vault = crate::org_session::open_org_vault(Some(dir))?;
let caller = vault.current_member()?; let caller = vault.current_member()?;
@@ -999,31 +1005,28 @@ pub fn run_edit(
crate::org_session::UnlockedOrgVault::ensure_grant(&caller, &collection)?; crate::org_session::UnlockedOrgVault::ensure_grant(&caller, &collection)?;
let mut item = vault.load_item(&collection, &id)?; let mut item = vault.load_item(&collection, &id)?;
eprintln!(
"Editing: {} ({}) — leave a prompt blank to keep the current value.",
item.title,
item.id.as_str()
);
if let Some(v) = crate::prompt::prompt_keep("Title", &item.title)? {
item.title = v;
}
if let Some(t) = title { item.title = t; } let history = &mut item.field_history;
match &mut item.core { match &mut item.core {
ItemCore::Login(l) => { ItemCore::Login(l) => ib::edit_login(l, history, totp_qr)?,
if let Some(u) = username { l.username = Some(u); } ItemCore::SecureNote(n) => ib::edit_secure_note(n, history)?,
if let Some(u) = url { ItemCore::Identity(i) => ib::edit_identity(i)?,
l.url = Some(url::Url::parse(&u).with_context(|| format!("invalid URL: {u}"))?); ItemCore::Card(c) => ib::edit_card(c, history)?,
} ItemCore::Key(k) => ib::edit_key(k, history)?,
if let Some(p) = password { l.password = Some(Zeroizing::new(p)); } ItemCore::Document(_) => ib::edit_document_message(),
} ItemCore::Totp(t) => ib::edit_totp(t, history)?,
ItemCore::SecureNote(n) => {
if let Some(b) = body { n.body = Zeroizing::new(b); }
}
ItemCore::Identity(i) => {
if let Some(v) = full_name { i.full_name = Some(v); }
if let Some(v) = email { i.email = Some(v); }
if let Some(v) = phone { i.phone = Some(v); }
}
_ => anyhow::bail!("org edit currently supports login, secure-note, and identity items"),
} }
item.modified = now_unix(); item.modified = now_unix();
let item_rel = vault.save_item(&collection, &item)?; let item_rel = vault.save_item(&collection, &item)?;
let mut manifest = vault.load_manifest()?; let mut manifest = vault.load_manifest()?;
upsert_org_entry(&mut manifest, &item, &collection); upsert_org_entry(&mut manifest, &item, &collection);
vault.save_manifest(&manifest)?; vault.save_manifest(&manifest)?;
@@ -1035,12 +1038,14 @@ pub fn run_edit(
); );
let commit_msg = format!( let commit_msg = format!(
"{subject}\n\nRelicario-Actor: {} {}\nRelicario-Action: item-update\nRelicario-Collection: {}\nRelicario-Item: {}", "{subject}\n\nRelicario-Actor: {} {}\nRelicario-Action: item-update\nRelicario-Collection: {}\nRelicario-Item: {}",
caller.display_name, caller.member_id.as_str(), collection, item.id.as_str() caller.display_name,
caller.member_id.as_str(),
collection,
item.id.as_str()
); );
crate::org_session::org_git_run(&vault.root, &["add", &item_rel, "manifest.enc"], "org edit: git add")?; crate::org_session::org_git_run(&vault.root, &["add", &item_rel, "manifest.enc"], "org edit: git add")?;
crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "org edit: git commit")?; crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "org edit: git commit")?;
println!("Updated {} ({}) in `{}`", item.title, item.id.as_str(), collection);
println!("Updated {}", item.id.as_str());
Ok(()) Ok(())
} }

View File

@@ -227,6 +227,8 @@ pub(crate) enum AddKind {
/// Prompt for password (vs reading from stdin or --password). /// Prompt for password (vs reading from stdin or --password).
#[arg(long)] password_prompt: bool, #[arg(long)] password_prompt: bool,
#[arg(long)] password: Option<String>, #[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)] group: Option<String>,
#[arg(long, value_delimiter = ',')] tags: Vec<String>, #[arg(long, value_delimiter = ',')] tags: Vec<String>,
#[arg(long)] favorite: bool, #[arg(long)] favorite: bool,
@@ -235,7 +237,8 @@ pub(crate) enum AddKind {
}, },
SecureNote { SecureNote {
#[arg(long)] title: Option<String>, #[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)] group: Option<String>,
#[arg(long, value_delimiter = ',')] tags: Vec<String>, #[arg(long, value_delimiter = ',')] tags: Vec<String>,
}, },
@@ -253,6 +256,12 @@ pub(crate) enum AddKind {
#[arg(long)] holder: Option<String>, #[arg(long)] holder: Option<String>,
#[arg(long)] expiry: Option<String>, // MM/YYYY #[arg(long)] expiry: Option<String>, // MM/YYYY
#[arg(long, default_value = "credit")] kind: String, #[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)] group: Option<String>,
#[arg(long, value_delimiter = ',')] tags: Vec<String>, #[arg(long, value_delimiter = ',')] tags: Vec<String>,
}, },
@@ -260,6 +269,8 @@ pub(crate) enum AddKind {
#[arg(long)] title: Option<String>, #[arg(long)] title: Option<String>,
#[arg(long)] label: Option<String>, #[arg(long)] label: Option<String>,
#[arg(long)] algorithm: 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)] group: Option<String>,
#[arg(long, value_delimiter = ',')] tags: Vec<String>, #[arg(long, value_delimiter = ',')] tags: Vec<String>,
}, },
@@ -274,6 +285,8 @@ pub(crate) enum AddKind {
#[arg(long)] issuer: Option<String>, #[arg(long)] issuer: Option<String>,
#[arg(long)] label: Option<String>, #[arg(long)] label: Option<String>,
#[arg(long)] secret: Option<String>, // base32 #[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 = "30")] period: u32,
#[arg(long, default_value = "6")] digits: u8, #[arg(long, default_value = "6")] digits: u8,
#[arg(long, default_value = "sha1")] algorithm: String, #[arg(long, default_value = "sha1")] algorithm: String,
@@ -522,18 +535,12 @@ pub(crate) enum OrgCommands {
List { List {
#[arg(long)] trashed: bool, #[arg(long)] trashed: bool,
}, },
/// Edit an org item's fields (flag-driven; blank flags keep current values). /// Edit an org item interactively (per-type prompts; blank keeps current).
Edit { Edit {
/// Item id or case-insensitive title substring. /// Item id or case-insensitive title substring.
query: String, query: String,
#[arg(long)] title: Option<String>, /// Replace the login TOTP secret from a QR image.
#[arg(long)] username: Option<String>, #[arg(long)] totp_qr: Option<std::path::PathBuf>,
#[arg(long)] url: Option<String>,
#[arg(long)] password: Option<String>,
#[arg(long)] body: Option<String>,
#[arg(long)] email: Option<String>,
#[arg(long)] phone: Option<String>,
#[arg(long)] full_name: Option<String>,
}, },
/// Soft-delete an org item (reversible via `org restore`). /// Soft-delete an org item (reversible via `org restore`).
Rm { query: String }, Rm { query: String },
@@ -553,13 +560,15 @@ pub(crate) enum OrgAddKind {
#[arg(long)] url: Option<String>, #[arg(long)] url: Option<String>,
#[arg(long)] password: Option<String>, #[arg(long)] password: Option<String>,
#[arg(long, value_delimiter = ',')] tags: Vec<String>, #[arg(long, value_delimiter = ',')] tags: Vec<String>,
#[arg(long)] password_stdin: bool,
}, },
/// A secure note. /// A secure note.
SecureNote { SecureNote {
#[arg(long)] collection: String, #[arg(long)] collection: String,
#[arg(long)] title: String, #[arg(long)] title: String,
#[arg(long)] body: String, #[arg(long)] body: Option<String>,
#[arg(long, value_delimiter = ',')] tags: Vec<String>, #[arg(long, value_delimiter = ',')] tags: Vec<String>,
#[arg(long)] body_stdin: bool,
}, },
/// An identity record. /// An identity record.
Identity { Identity {
@@ -570,6 +579,41 @@ pub(crate) enum OrgAddKind {
#[arg(long)] phone: Option<String>, #[arg(long)] phone: Option<String>,
#[arg(long, value_delimiter = ',')] tags: Vec<String>, #[arg(long, value_delimiter = ',')] tags: Vec<String>,
}, },
/// A payment card (number / cvv / pin entered via --*-stdin or prompt).
Card {
#[arg(long)] collection: String,
#[arg(long)] title: String,
#[arg(long)] holder: Option<String>,
#[arg(long)] expiry: Option<String>,
#[arg(long, default_value = "credit")] kind: String,
#[arg(long, value_delimiter = ',')] tags: Vec<String>,
#[arg(long)] number_stdin: bool,
#[arg(long)] cvv_stdin: bool,
#[arg(long)] pin_stdin: bool,
},
/// A key / credential blob (material entered via --material-stdin or prompt).
Key {
#[arg(long)] collection: String,
#[arg(long)] title: String,
#[arg(long)] label: Option<String>,
#[arg(long)] algorithm: Option<String>,
#[arg(long)] public_key: Option<String>,
#[arg(long, value_delimiter = ',')] tags: Vec<String>,
#[arg(long)] material_stdin: bool,
},
/// A TOTP authenticator (base32 secret via --secret or --secret-stdin).
Totp {
#[arg(long)] collection: String,
#[arg(long)] title: String,
#[arg(long)] issuer: Option<String>,
#[arg(long)] label: Option<String>,
#[arg(long)] secret: Option<String>,
#[arg(long, default_value_t = 30)] period: u32,
#[arg(long, default_value_t = 6)] digits: u8,
#[arg(long, default_value = "sha1")] algorithm: String,
#[arg(long, value_delimiter = ',')] tags: Vec<String>,
#[arg(long)] secret_stdin: bool,
},
} }
fn main() -> Result<()> { fn main() -> Result<()> {
@@ -663,14 +707,14 @@ fn main() -> Result<()> {
OrgCommands::Add { kind } => { OrgCommands::Add { kind } => {
let d = crate::org_session::org_dir(dir_path)?; let d = crate::org_session::org_dir(dir_path)?;
let (collection, add_kind, tags) = match kind { let (collection, add_kind, tags) = match kind {
OrgAddKind::Login { collection, title, username, url, password, tags } => ( OrgAddKind::Login { collection, title, username, url, password, tags, password_stdin } => (
collection, collection,
commands::org::OrgAddKind::Login { title, username, url, password }, commands::org::OrgAddKind::Login { title, username, url, password, password_stdin },
tags, tags,
), ),
OrgAddKind::SecureNote { collection, title, body, tags } => ( OrgAddKind::SecureNote { collection, title, body, tags, body_stdin } => (
collection, collection,
commands::org::OrgAddKind::SecureNote { title, body }, commands::org::OrgAddKind::SecureNote { title, body, body_stdin },
tags, tags,
), ),
OrgAddKind::Identity { collection, title, full_name, email, phone, tags } => ( OrgAddKind::Identity { collection, title, full_name, email, phone, tags } => (
@@ -678,6 +722,21 @@ fn main() -> Result<()> {
commands::org::OrgAddKind::Identity { title, full_name, email, phone }, commands::org::OrgAddKind::Identity { title, full_name, email, phone },
tags, tags,
), ),
OrgAddKind::Card { collection, title, holder, expiry, kind, tags, number_stdin, cvv_stdin, pin_stdin } => (
collection,
commands::org::OrgAddKind::Card { title, holder, expiry, kind, number_stdin, cvv_stdin, pin_stdin },
tags,
),
OrgAddKind::Key { collection, title, label, algorithm, public_key, tags, material_stdin } => (
collection,
commands::org::OrgAddKind::Key { title, label, algorithm, public_key, material_stdin },
tags,
),
OrgAddKind::Totp { collection, title, issuer, label, secret, period, digits, algorithm, tags, secret_stdin } => (
collection,
commands::org::OrgAddKind::Totp { title, issuer, label, secret, secret_stdin, period, digits, algorithm },
tags,
),
}; };
commands::org::run_add(&d, &collection, add_kind, tags)?; commands::org::run_add(&d, &collection, add_kind, tags)?;
} }
@@ -689,9 +748,9 @@ fn main() -> Result<()> {
let d = crate::org_session::org_dir(dir_path)?; let d = crate::org_session::org_dir(dir_path)?;
commands::org::run_list(&d, trashed)?; commands::org::run_list(&d, trashed)?;
} }
OrgCommands::Edit { query, title, username, url, password, body, email, phone, full_name } => { OrgCommands::Edit { query, totp_qr } => {
let d = crate::org_session::org_dir(dir_path)?; let d = crate::org_session::org_dir(dir_path)?;
commands::org::run_edit(&d, &query, title, username, url, password, body, email, phone, full_name)?; commands::org::run_edit(&d, &query, totp_qr)?;
} }
OrgCommands::Rm { query } => { OrgCommands::Rm { query } => {
let d = crate::org_session::org_dir(dir_path)?; let d = crate::org_session::org_dir(dir_path)?;

View File

@@ -1,13 +1,12 @@
//! Interactive prompt helpers for the CLI. //! Interactive prompt helpers for the CLI.
//! //!
//! The `prompt`/`prompt_optional`/`prompt_secret` family reads from stdin / //! `prompt_secret` reads a masked secret from the TTY (honouring
//! the TTY; the `prompt_keep`/`prompt_keep_opt`/`prompt_yesno` variants are //! `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 //! 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` //! at a blank prompt. `prompt_or_flag` and `prompt_or_flag_optional` thread a
//! so integration tests (which don't have a TTY) can inject secrets. //! CLI-flag value through the same path so command handlers can use one call
//! `prompt_or_flag` and `prompt_or_flag_optional` thread a CLI-flag value //! site whether the value came from the command line or an interactive prompt.
//! 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.
use anyhow::Result; use anyhow::Result;
use std::io::BufRead; 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) }) 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>> { pub(crate) fn prompt_keep(label: &str, current: &str) -> Result<Option<String>> {
eprint!("{label} [{current}]: "); eprint!("{label} [{current}]: ");
std::io::Write::flush(&mut std::io::stderr())?; std::io::Write::flush(&mut std::io::stderr())?;

View File

@@ -201,3 +201,20 @@ fn generate_random_and_bip39() {
let phrase = String::from_utf8(out.stdout).unwrap(); let phrase = String::from_utf8(out.stdout).unwrap();
assert_eq!(phrase.trim().split(' ').count(), 5); 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}");
}

View File

@@ -152,7 +152,9 @@ fn org_get_edit_rm_restore_purge_reject_ungranted_member() {
); );
for (label, args) in [ for (label, args) in [
("edit", vec!["org", "edit", "GitHub", "--username", "evil"]), // `org edit` is now interactive (no flat flags); the ungranted member is
// rejected at manifest lookup, before any prompt is read.
("edit", vec!["org", "edit", "GitHub"]),
("rm", vec!["org", "rm", "GitHub"]), ("rm", vec!["org", "rm", "GitHub"]),
("restore", vec!["org", "restore", "GitHub"]), ("restore", vec!["org", "restore", "GitHub"]),
("purge", vec!["org", "purge", "GitHub"]), ("purge", vec!["org", "purge", "GitHub"]),
@@ -170,13 +172,12 @@ fn org_get_edit_rm_restore_purge_reject_ungranted_member() {
} }
// The item is untouched: the owner can still read the original password and // The item is untouched: the owner can still read the original password and
// the username was NOT changed to the ungranted member's "evil" attempt. // username the ungranted member's get/edit/rm/restore/purge were all denied.
let owner_get = owner_dev.run(vault, &["org", "get", "GitHub", "--show"]); let owner_get = owner_dev.run(vault, &["org", "get", "GitHub", "--show"]);
let owner_out = String::from_utf8_lossy(&owner_get.stdout).to_string(); let owner_out = String::from_utf8_lossy(&owner_get.stdout).to_string();
assert!(owner_get.status.success(), "owner should still read the item"); assert!(owner_get.status.success(), "owner should still read the item");
assert!(owner_out.contains("hunter2"), "owner read must still show original password: {owner_out}"); assert!(owner_out.contains("hunter2"), "owner read must still show original password: {owner_out}");
assert!(owner_out.contains("alice"), "edit by ungranted member must not have changed username: {owner_out}"); assert!(owner_out.contains("alice"), "ungranted member must not have modified the item: {owner_out}");
assert!(!owner_out.contains("evil"), "ungranted edit leaked through: {owner_out}");
} }
#[test] #[test]

View File

@@ -67,6 +67,39 @@ impl OrgFixture {
let v: serde_json::Value = serde_json::from_str(&s).unwrap(); let v: serde_json::Value = serde_json::from_str(&s).unwrap();
v["members"][0]["member_id"].as_str().unwrap().to_string() v["members"][0]["member_id"].as_str().unwrap().to_string()
} }
/// Like `run`, but pipes `stdin_data` into the child's stdin — used to drive
/// `--*-stdin` secret flags and the interactive edit prompts. `wait_with_output`
/// closes stdin for us, so multiline secrets (read-to-EOF) terminate cleanly.
fn run_stdin(&self, args: &[&str], stdin_data: &str) -> std::process::Output {
use std::io::Write as _;
let mut child = Command::cargo_bin("relicario")
.unwrap()
.env("XDG_CONFIG_HOME", &self.xdg)
.env("RELICARIO_ORG_DIR", self.vault.path())
.args(args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
child.stdin.as_mut().unwrap().write_all(stdin_data.as_bytes()).unwrap();
child.wait_with_output().unwrap()
}
/// Create collection `slug` and grant the owner access to it — the common
/// setup the item-type round-trip tests share.
fn create_collection_and_grant(&self, slug: &str) {
let owner = self.owner_member_id();
assert!(
self.run(&["org", "create-collection", slug, "--name", slug]).status.success(),
"create-collection {slug} failed",
);
assert!(
self.run(&["org", "grant", &owner, slug]).status.success(),
"grant {slug} failed",
);
}
} }
#[test] #[test]
@@ -151,21 +184,17 @@ fn org_add_rejects_unknown_collection() {
#[test] #[test]
fn org_edit_updates_fields_and_commits_update_trailer() { fn org_edit_updates_fields_and_commits_update_trailer() {
let f = OrgFixture::new(); let f = OrgFixture::new();
let owner = f.owner_member_id(); f.create_collection_and_grant("prod");
assert!(f.run(&["org", "create-collection", "prod", "--name", "Production"]).status.success());
assert!(f.run(&["org", "grant", &owner, "prod"]).status.success());
assert!(f.run(&[ assert!(f.run(&[
"org", "add", "login", "--collection", "prod", "org", "add", "login", "--collection", "prod",
"--title", "Mail", "--username", "old", "--password", "pw", "--title", "Mail", "--username", "old", "--password", "pw",
]).status.success()); ]).status.success());
// Edit the username. // org edit is now interactive per-type: keep title, set username=new-user,
let out = f.run(&[ // keep URL, decline password change.
"org", "edit", "Mail", "--username", "new-user", let out = f.run_stdin(&["org", "edit", "Mail"], "\nnew-user\n\nn\n");
]);
assert!(out.status.success(), "org edit: {}", String::from_utf8_lossy(&out.stderr)); assert!(out.status.success(), "org edit: {}", String::from_utf8_lossy(&out.stderr));
// get --show reflects the new username.
let out = f.run(&["org", "get", "Mail", "--show"]); let out = f.run(&["org", "get", "Mail", "--show"]);
let stdout = String::from_utf8_lossy(&out.stdout).to_string(); let stdout = String::from_utf8_lossy(&out.stdout).to_string();
assert!(stdout.contains("new-user"), "edit did not take: {stdout}"); assert!(stdout.contains("new-user"), "edit did not take: {stdout}");
@@ -215,3 +244,215 @@ fn org_rm_restore_purge_cycle() {
let body = String::from_utf8_lossy(&log.stdout).to_string(); let body = String::from_utf8_lossy(&log.stdout).to_string();
assert!(body.contains("Relicario-Action: item-purge"), "missing purge trailer: {body}"); assert!(body.contains("Relicario-Action: item-purge"), "missing purge trailer: {body}");
} }
// --- v0.8.1 org item-type parity: Card / Key / Totp -------------------------
// These drive the new `org add <card|key|totp>` subcommands. Secrets enter via
// `--*-stdin` (read from piped stdin) or, for Totp, the `--secret` flag. `org get`
// must mask every secret unless `--show` is passed — asserted below.
#[test]
fn org_add_card_via_stdin_then_get_masks_secret() {
let f = OrgFixture::new();
f.create_collection_and_grant("eng");
// build_card reads number, then cvv, then pin — one line each, in that order.
let out = f.run_stdin(
&[
"org", "add", "card", "--collection", "eng", "--title", "Corp Visa",
"--kind", "credit", "--number-stdin", "--cvv-stdin", "--pin-stdin",
],
"4111111111111111\n123\n4321\n",
);
assert!(out.status.success(), "add card: {}", String::from_utf8_lossy(&out.stderr));
// get masks the card number by default.
let got = f.run(&["org", "get", "Corp Visa"]);
let stdout = String::from_utf8_lossy(&got.stdout).to_string();
assert!(stdout.contains("Corp Visa"), "title missing: {stdout}");
assert!(stdout.contains("********"), "card number must be masked without --show: {stdout}");
assert!(!stdout.contains("4111111111111111"), "secret leaked without --show: {stdout}");
// --show reveals it.
let shown = f.run(&["org", "get", "Corp Visa", "--show"]);
let shown = String::from_utf8_lossy(&shown.stdout).to_string();
assert!(shown.contains("4111111111111111"), "number not revealed with --show: {shown}");
}
#[test]
fn org_add_key_via_stdin_then_get_masks_material() {
let f = OrgFixture::new();
f.create_collection_and_grant("eng");
// build_key reads key material from stdin to EOF (multiline secret).
let out = f.run_stdin(
&[
"org", "add", "key", "--collection", "eng", "--title", "Deploy Key",
"--label", "ci", "--algorithm", "ed25519", "--material-stdin",
],
"-----BEGIN OPENSSH PRIVATE KEY-----\nAAAAsecretmaterial\n-----END OPENSSH PRIVATE KEY-----\n",
);
assert!(out.status.success(), "add key: {}", String::from_utf8_lossy(&out.stderr));
let got = f.run(&["org", "get", "Deploy Key"]);
let stdout = String::from_utf8_lossy(&got.stdout).to_string();
assert!(stdout.contains("Label: ci"), "label missing: {stdout}");
assert!(stdout.contains("********"), "key material must be masked without --show: {stdout}");
assert!(!stdout.contains("secretmaterial"), "key material leaked without --show: {stdout}");
}
#[test]
fn org_add_totp_with_secret_flag_round_trips() {
let f = OrgFixture::new();
f.create_collection_and_grant("eng");
// Totp accepts the base32 secret via --secret (no stdin needed).
let out = f.run(&[
"org", "add", "totp", "--collection", "eng", "--title", "AWS root",
"--issuer", "AWS", "--secret", "JBSWY3DPEHPK3PXP",
]);
assert!(out.status.success(), "add totp: {}", String::from_utf8_lossy(&out.stderr));
let got = f.run(&["org", "get", "AWS root"]);
let stdout = String::from_utf8_lossy(&got.stdout).to_string();
assert!(stdout.contains("AWS root"), "title missing: {stdout}");
assert!(stdout.contains("Issuer: AWS"), "issuer missing: {stdout}");
}
#[test]
fn org_edit_card_interactive_changes_holder() {
let f = OrgFixture::new();
f.create_collection_and_grant("eng");
let out = f.run_stdin(
&[
"org", "add", "card", "--collection", "eng", "--title", "Corp Visa",
"--kind", "credit", "--number-stdin", "--cvv-stdin", "--pin-stdin",
],
"4111111111111111\n123\n4321\n",
);
assert!(out.status.success(), "add card: {}", String::from_utf8_lossy(&out.stderr));
// Interactive edit: keep title, set holder, decline number change.
let out = f.run_stdin(&["org", "edit", "Corp Visa"], "\nJane Q. Public\nn\n");
assert!(out.status.success(), "org edit card: {}", String::from_utf8_lossy(&out.stderr));
let got = f.run(&["org", "get", "Corp Visa"]);
let stdout = String::from_utf8_lossy(&got.stdout).to_string();
assert!(stdout.contains("Holder: Jane Q. Public"), "holder edit did not take: {stdout}");
assert!(stdout.contains("********"), "number must stay masked after declining change: {stdout}");
assert!(!stdout.contains("4111111111111111"), "number leaked without --show: {stdout}");
}
#[test]
fn org_edit_totp_interactive_changes_issuer() {
let f = OrgFixture::new();
f.create_collection_and_grant("eng");
assert!(f.run(&[
"org", "add", "totp", "--collection", "eng", "--title", "AWS root",
"--issuer", "AWS", "--secret", "JBSWY3DPEHPK3PXP",
]).status.success());
// Interactive edit: keep title, set issuer=GitHub, keep label, decline secret change.
let out = f.run_stdin(&["org", "edit", "AWS root"], "\nGitHub\n\nn\n");
assert!(out.status.success(), "org edit totp: {}", String::from_utf8_lossy(&out.stderr));
let got = f.run(&["org", "get", "AWS root"]);
assert!(String::from_utf8_lossy(&got.stdout).contains("Issuer: GitHub"), "issuer edit did not take");
}
// --- grant enforcement + remaining --*-stdin paths for the new types ---------
#[test]
fn org_add_card_key_totp_reject_ungranted_and_unknown_collection() {
let f = OrgFixture::new();
// `secret` exists but is NOT granted to the owner.
assert!(f.run(&["org", "create-collection", "secret", "--name", "secret"]).status.success());
// ensure_grant runs before any secret prompt in run_add, so these need no
// stdin — each new type must be rejected for a collection it lacks a grant for.
for args in [
vec!["org", "add", "card", "--collection", "secret", "--title", "X", "--kind", "credit"],
vec!["org", "add", "key", "--collection", "secret", "--title", "X"],
vec!["org", "add", "totp", "--collection", "secret", "--title", "X", "--secret", "JBSWY3DPEHPK3PXP"],
] {
let out = f.run(&args);
assert!(!out.status.success(), "ungranted add must fail: {args:?}");
let err = String::from_utf8_lossy(&out.stderr).to_string();
assert!(err.contains("access denied") || err.contains("grant"),
"expected grant denial for {args:?}: {err}");
}
// …and rejected for a nonexistent collection.
for args in [
vec!["org", "add", "card", "--collection", "ghost", "--title", "X", "--kind", "credit"],
vec!["org", "add", "key", "--collection", "ghost", "--title", "X"],
vec!["org", "add", "totp", "--collection", "ghost", "--title", "X", "--secret", "JBSWY3DPEHPK3PXP"],
] {
let out = f.run(&args);
assert!(!out.status.success(), "unknown-collection add must fail: {args:?}");
let err = String::from_utf8_lossy(&out.stderr).to_string();
assert!(err.contains("does not exist") || err.contains("ghost"),
"expected unknown-collection error for {args:?}: {err}");
}
}
#[test]
fn org_add_secure_note_via_body_stdin_masks_body() {
let f = OrgFixture::new();
f.create_collection_and_grant("eng");
// build_secure_note(body_stdin=true) reads the body from stdin to EOF.
let out = f.run_stdin(
&["org", "add", "secure-note", "--collection", "eng", "--title", "Runbook", "--body-stdin"],
"line one\nsuper-secret-line\n",
);
assert!(out.status.success(), "add note: {}", String::from_utf8_lossy(&out.stderr));
let got = f.run(&["org", "get", "Runbook"]);
let stdout = String::from_utf8_lossy(&got.stdout).to_string();
assert!(stdout.contains("********"), "note body must be masked without --show: {stdout}");
assert!(!stdout.contains("super-secret-line"), "note body leaked without --show: {stdout}");
let shown = f.run(&["org", "get", "Runbook", "--show"]);
assert!(String::from_utf8_lossy(&shown.stdout).contains("super-secret-line"), "body not revealed with --show");
}
#[test]
fn org_add_totp_via_secret_stdin_round_trips() {
let f = OrgFixture::new();
f.create_collection_and_grant("eng");
// build_totp(secret_stdin=true) reads one base32 line from stdin.
let out = f.run_stdin(
&["org", "add", "totp", "--collection", "eng", "--title", "VPN", "--issuer", "Corp", "--secret-stdin"],
"JBSWY3DPEHPK3PXP\n",
);
assert!(out.status.success(), "add totp: {}", String::from_utf8_lossy(&out.stderr));
let got = f.run(&["org", "get", "VPN"]);
assert!(String::from_utf8_lossy(&got.stdout).contains("Issuer: Corp"), "issuer missing");
}
#[test]
fn org_edit_key_replaces_material_and_reveals_with_show() {
let f = OrgFixture::new();
f.create_collection_and_grant("eng");
let out = f.run_stdin(
&["org", "add", "key", "--collection", "eng", "--title", "Signing Key",
"--label", "ci", "--material-stdin"],
"OLD-MATERIAL-aaaa\n",
);
assert!(out.status.success(), "add key: {}", String::from_utf8_lossy(&out.stderr));
// Interactive edit: keep title, ACCEPT "Replace key material?" -> new material
// read from stdin to EOF (edit_key). Exercises the accept branch + history push.
let out = f.run_stdin(&["org", "edit", "Signing Key"], "\ny\nNEW-MATERIAL-bbbb\n");
assert!(out.status.success(), "org edit key: {}", String::from_utf8_lossy(&out.stderr));
let masked = f.run(&["org", "get", "Signing Key"]);
let masked = String::from_utf8_lossy(&masked.stdout).to_string();
assert!(masked.contains("********"), "material must be masked without --show: {masked}");
assert!(!masked.contains("NEW-MATERIAL"), "material leaked without --show: {masked}");
let shown = f.run(&["org", "get", "Signing Key", "--show"]);
let shown = String::from_utf8_lossy(&shown.stdout).to_string();
assert!(shown.contains("NEW-MATERIAL-bbbb"), "replaced material not revealed with --show: {shown}");
assert!(!shown.contains("OLD-MATERIAL"), "old material still present after replace: {shown}");
}