Dev-A shared item_build foundation + personal --*-stdin; Dev-B org Card/Key/Totp; Dev-C org Document + attachment storage; Dev-D server hook grant-scoping. TDD tasks with full code; A gates B/C, D independent. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L5JvzEse4xUxLZKhofyeCD
59 KiB
v0.8.1 Org Vault Item-Type Parity — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Bring relicario org add / relicario org edit to full item-type parity with the personal vault by adding Card, Key, Document, and Totp support, with secrets entered via interactive prompts plus --*-stdin escape hatches, including the relicario-server hook change Document requires.
Architecture: Extract per-type item construction, secret resolution, and interactive-edit logic into one shared CLI module (commands/item_build.rs) that both the personal and org command handlers call — eliminating the current personal↔org builder duplication. Org gains collection-scoped attachment storage; the pre-receive hook is extended to grant-scope attachment write paths.
Tech Stack: Rust (relicario-cli, relicario-core, relicario-server), clap, anyhow, zeroize::Zeroizing, assert_cmd + tempfile for CLI integration tests, ssh-keygen for the org test fixture.
Spec: docs/superpowers/specs/2026-06-20-relicario-v0.8.1-parity.md
Global Constraints
- Capitalization: "Relicario" with a capital R in prose / UI / CLI help text; lowercase
relicariofor the binary, crate names, identifiers, and paths. - Secret-input model: single-line secrets resolve as
--*-stdin(read one trimmed-of-trailing-newline line from stdin) → else interactiveprompt_secret(which honoursRELICARIO_TEST_ITEM_SECRET). Multiline secrets (key material, note body) always read stdin to EOF; the--*-stdinflag only suppresses the interactive "paste; Ctrl-D" hint. - Field history: core-field edits record prior values under synthetic
FieldId(format!("core:{key}"))keys viapush_history— never invent a different scheme. - Org commits: every org write is committed through
crate::org_session::org_git_runwith the existing structured trailers (Relicario-Actor,Relicario-Action,Relicario-Collection,Relicario-Item). Do not introduce an unsigned commit path. - Org storage layout: items
items/<slug>/<id>.enc; attachments (new)attachments/<slug>/<item-id>/<att-id>.enc. The leading<slug>is what the hook authorizes against — it is never decrypted server-side. - Discipline: DRY, YAGNI, TDD (failing test first), frequent commits. Conventional-commit messages.
- Hook redeploy: the
relicario-serverchange (Dev-D) requires the deployed pre-receive hook to be rebuilt; the release notes must call this out.
File Structure
| Path | Responsibility | Stream |
|---|---|---|
crates/relicario-cli/src/commands/item_build.rs |
NEW. Shared secret-resolution helpers, type parsers, per-type build_* item builders, per-type interactive edit_* helpers + push_history. |
A |
crates/relicario-cli/src/commands/mod.rs |
Register pub mod item_build;. |
A |
crates/relicario-cli/src/commands/add.rs |
cmd_add delegates to shared builders; sets group/tags/favorite post-build. |
A |
crates/relicario-cli/src/commands/edit.rs |
cmd_edit delegates to shared edit_* helpers. |
A |
crates/relicario-cli/src/main.rs |
Personal AddKind --*-stdin flags (A); org OrgAddKind Card/Key/Totp/Document variants + dispatch (B/C); OrgCommands::Edit → query-only interactive (B). |
A/B/C |
crates/relicario-cli/src/commands/org.rs |
commands::org::OrgAddKind new variants; run_add arms; run_edit per-type interactive dispatch; run_purge removes attachments. |
B/C |
crates/relicario-cli/src/org_session.rs |
Attachment path/save/load/remove methods + default cap constant. | C |
crates/relicario-cli/tests/org_items.rs |
Card/Key/Totp/Document add→get→edit→rm round-trips; secret masking; grant-denied attachment write. | B/C |
crates/relicario-server/src/lib.rs |
classify_path recognizes attachments/<slug>/<item-id>/<att-id>.enc → Item{collection}. |
D |
crates/relicario-server/tests/org_hook.rs |
Attachment classification tests. | D |
crates/relicario-server/Cargo.toml |
Version bump. | D |
docs/FORMATS.md, crates/relicario-cli/ARCHITECTURE.md, docs/SECURITY.md |
Living-docs per stream. | C/A/D |
Stream dependency graph
Dev-A (foundation: shared module + personal refactor + personal --*-stdin)
├──> Dev-B (org Card/Key/Totp) ──> [org.rs] ──> Dev-C (org Document + attachments)
└──> Dev-C (build_document)
Dev-D (server hook) ── independent, may start at minute zero
- Dev-A must merge before B and C integrate (they consume its public interface).
- Dev-B establishes the org per-type
OrgAddKind/run_editdispatch skeleton incommands/org.rs; Dev-C adds the Document arm into that skeleton, so B merges before C (C rebases on B). They shareorg.rsandorg_items.rs. - Dev-D touches only
relicario-server— zero overlap; run it fully in parallel.
Dev-A — Shared item-build foundation
Interfaces produced (consumed by B and C):
// crates/relicario-cli/src/commands/item_build.rs
use std::path::PathBuf;
use std::collections::HashMap;
use anyhow::Result;
use zeroize::Zeroizing;
use relicario_core::{Item, EncryptedAttachment, FieldId};
use relicario_core::item::FieldHistoryEntry;
pub(crate) type FieldHistory = HashMap<FieldId, Vec<FieldHistoryEntry>>;
// --- secret resolution ---
pub(crate) fn resolve_secret_line(from_stdin: bool, label: &str) -> Result<String>;
pub(crate) fn resolve_secret_multiline(from_stdin: bool, hint: &str) -> Result<String>;
// --- parsers ---
pub(crate) fn parse_card_kind(s: &str) -> Result<relicario_core::item_types::CardKind>;
pub(crate) fn parse_totp_algorithm(s: &str) -> Result<relicario_core::item_types::TotpAlgorithm>;
// --- builders (return Item with title + core only; caller sets group/tags/favorite) ---
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>;
pub(crate) fn build_secure_note(title: String, body: Option<String>, body_stdin: bool) -> Result<Item>;
pub(crate) fn build_identity(title: String, full_name: Option<String>, email: Option<String>,
phone: Option<String>, date_of_birth: Option<String>) -> Result<Item>;
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>;
pub(crate) fn build_key(title: String, label: Option<String>, algorithm: Option<String>,
public_key: Option<String>, material_stdin: bool) -> Result<Item>;
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>;
pub(crate) fn build_document(title: String, file: PathBuf,
key: &Zeroizing<[u8; 32]>, max_bytes: u64) -> Result<(Item, EncryptedAttachment)>;
// --- interactive edit helpers (mutate core in place; record history) ---
pub(crate) fn edit_login(l: &mut relicario_core::item_types::LoginCore, history: &mut FieldHistory, totp_qr: Option<PathBuf>) -> Result<()>;
pub(crate) fn edit_secure_note(n: &mut relicario_core::item_types::SecureNoteCore, history: &mut FieldHistory) -> Result<()>;
pub(crate) fn edit_identity(i: &mut relicario_core::item_types::IdentityCore) -> Result<()>;
pub(crate) fn edit_card(c: &mut relicario_core::item_types::CardCore, history: &mut FieldHistory) -> Result<()>;
pub(crate) fn edit_key(k: &mut relicario_core::item_types::KeyCore, history: &mut FieldHistory) -> Result<()>;
pub(crate) fn edit_totp(t: &mut relicario_core::item_types::TotpCore, history: &mut FieldHistory) -> Result<()>;
pub(crate) fn edit_document_message();
pub(crate) fn push_history(history: &mut FieldHistory, synthetic_key: &str, old_value: Zeroizing<String>);
Task A1: Shared module scaffold — secret resolution + parsers
Files:
-
Create:
crates/relicario-cli/src/commands/item_build.rs -
Modify:
crates/relicario-cli/src/commands/mod.rs(addpub mod item_build;) -
Test: inline
#[cfg(test)]initem_build.rs -
Step 1: Register the module. In
crates/relicario-cli/src/commands/mod.rsadd (alongside the otherpub modlines):
pub mod item_build;
- Step 2: Write the failing parser tests. Create
crates/relicario-cli/src/commands/item_build.rswith only the test module first:
#[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());
}
}
-
Step 3: Run to verify failure. Run:
cargo test -p relicario-cli --lib item_buildExpected: FAIL —parse_card_kind/parse_totp_algorithmnot found. -
Step 4: Implement helpers + parsers. Prepend above the test module in
item_build.rs:
//! 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::{EncryptedAttachment, FieldId, Item, ItemCore};
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}"),
})
}
-
Step 5: Run to verify pass. Run:
cargo test -p relicario-cli --lib item_buildExpected: PASS (4 tests). -
Step 6: Commit.
git add crates/relicario-cli/src/commands/item_build.rs crates/relicario-cli/src/commands/mod.rs
git commit -m "feat(cli): shared item_build module — secret resolution + type parsers"
Task A2: Move interactive edit helpers into the shared module
Files:
- Modify:
crates/relicario-cli/src/commands/item_build.rs(addedit_*+push_history) - Modify:
crates/relicario-cli/src/commands/edit.rs(delete the moved fns; call shared ones) - Test: existing
crates/relicario-cli/tests/edit_and_history.rs(behavior-preserving)
Interfaces produced: the six edit_* functions + edit_document_message + push_history (signatures in the Dev-A header).
- Step 1: Move the helpers verbatim. Cut these functions from
crates/relicario-cli/src/commands/edit.rsand paste them intoitem_build.rs:edit_login,edit_secure_note,edit_identity,edit_card,edit_key,edit_document_message,edit_totp,push_history, and the localtype FieldHistory(delete the duplicatetype FieldHistoryin edit.rs — use the shared one). Change each from privatefntopub(crate) fn. They already usecrate::prompt::*,relicario_core::*,crate::parse::base32_decode_lenient— keep those paths (they resolve identically fromitem_build.rs). Add the neededuselines toitem_build.rs:
use crate::parse::base32_decode_lenient;
use crate::prompt::{prompt_keep_opt, prompt_secret, prompt_yesno};
use relicario_core::time::now_unix;
- Step 2: Rewire edit.rs dispatch. In
crates/relicario-cli/src/commands/edit.rs::cmd_edit, the per-type match becomes calls into the shared module:
let history = &mut item.field_history;
match &mut item.core {
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)?,
}
Remove now-unused imports from edit.rs (e.g. prompt_secret, prompt_yesno, base32_decode_lenient if no longer referenced). Keep prompt_keep/prompt_keep_opt only if the title/group/tags prompts at the top of cmd_edit still use them.
-
Step 3: Run to verify it fails first if anything diverged. Run:
cargo build -p relicario-cliExpected: compiles. If a moved fn references a symbol not imported initem_build.rs, fix theuse. -
Step 4: Run the edit/history tests. Run:
cargo test -p relicario-cli --test edit_and_historyExpected: PASS — behavior is unchanged (functions only moved + re-exported). -
Step 5: Commit.
git add crates/relicario-cli/src/commands/item_build.rs crates/relicario-cli/src/commands/edit.rs
git commit -m "refactor(cli): move per-type edit helpers into shared item_build module"
Task A3: Move add builders into the shared module
Files:
- Modify:
crates/relicario-cli/src/commands/item_build.rs(addbuild_*) - Modify:
crates/relicario-cli/src/commands/add.rs(delegate; set group/tags/favorite) - Test: existing
crates/relicario-cli/tests/basic_flows.rs,attachments.rs
Interfaces produced: the seven build_* functions (signatures in the Dev-A header).
- Step 1: Add the builders to
item_build.rs. Port the bodies from the currentadd.rsbuild_*_itemfns, with these changes: builders take an already-resolvedtitle: String; they set onlytitle+ core (NOT group/tags/favorite); secrets go throughresolve_secret_line/resolve_secret_multiline:
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, TotpAlgorithm, 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(true, "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,
})))
}
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))
}
- Step 2: Rewire
cmd_add. Incrates/relicario-cli/src/commands/add.rs, delete the localbuild_*_itemfns and replacecmd_add's match so each arm resolvestitle(viaprompt_or_flag), calls the shared builder, then setsgroup/tags/favorite. Document writes the encrypted blob to the personal path. Example arms:
use crate::commands::item_build as ib;
use crate::prompt::{prompt_or_flag, prompt_or_flag_optional};
let item = match kind {
AddKind::Login { title, username, url, password_prompt, password, 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*/ false, password_prompt, totp_qr)?;
item.group = group; item.tags = tags; item.favorite = favorite;
item
}
AddKind::Card { title, holder, expiry, kind, group, tags } => {
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
let mut item = ib::build_card(title, holder, expiry, &kind, false, false, false)?;
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
}
// SecureNote / Identity / Key / Totp arms follow the same shape, passing
// `false` for the new *_stdin booleans (A4 wires the real flags).
// ...
};
The
*_stdinbooleans are hard-wiredfalsehere; Task A4 adds the flags and threads the real values. This keeps A3 a pure, behavior-preserving refactor.
-
Step 3: Run the failing build. Run:
cargo build -p relicario-cliExpected: compiles after all seven arms are ported. -
Step 4: Run personal add tests. Run:
cargo test -p relicario-cli --test basic_flows --test attachmentsExpected: PASS — personal add behavior unchanged. -
Step 5: Commit.
git add crates/relicario-cli/src/commands/item_build.rs crates/relicario-cli/src/commands/add.rs
git commit -m "refactor(cli): personal add delegates to shared item_build builders"
Task A4: Add --*-stdin secret flags to the personal CLI
Files:
-
Modify:
crates/relicario-cli/src/main.rs(AddKindvariants + dispatch) -
Modify:
crates/relicario-cli/src/commands/add.rs(thread the new booleans) -
Test:
crates/relicario-cli/tests/basic_flows.rs(new stdin-driven case) -
Step 1: Write the failing test. Append to
crates/relicario-cli/tests/basic_flows.rsa test that adds a Card non-interactively via--*-stdin(model the secret-piping on the existing fixture; pipenumber\ncvv\npin\n):
#[test]
fn add_card_via_stdin_flags_is_non_interactive() {
let v = TestVault::new(); // existing personal-vault fixture in this file
let mut cmd = v.cmd(&["add", "card", "--title", "Visa", "--kind", "credit",
"--number-stdin", "--cvv-stdin", "--pin-stdin"]);
cmd.write_stdin("4111111111111111\n123\n4321\n");
let out = cmd.output().unwrap();
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
// The card lands and its number is masked without --show.
let got = v.run(&["get", "Visa"]);
assert!(String::from_utf8_lossy(&got.stdout).contains("********"));
}
If the personal fixture in
basic_flows.rslacks awrite_stdinhelper, useassert_cmd'sCommand::write_stdinon thecargo_bincommand directly, mirroringorg_items.rs'srunbut withStdio::piped()stdin.
-
Step 2: Run to verify failure. Run:
cargo test -p relicario-cli --test basic_flows add_card_via_stdin_flagsExpected: FAIL —--number-stdinis an unknown argument. -
Step 3: Add the flags. In
crates/relicario-cli/src/main.rs, extend the relevantAddKindvariants with boolean stdin flags (clap#[arg(long)], default false). Card example:
Card {
#[arg(long)] title: Option<String>,
#[arg(long)] holder: Option<String>,
#[arg(long)] expiry: Option<String>,
#[arg(long, default_value = "credit")] kind: String,
#[arg(long)] group: Option<String>,
#[arg(long, value_delimiter = ',')] tags: Vec<String>,
#[arg(long)] number_stdin: bool,
#[arg(long)] cvv_stdin: bool,
#[arg(long)] pin_stdin: bool,
},
Add password_stdin to Login, body_stdin to SecureNote, material_stdin to Key, secret_stdin to Totp. (Document has no secret flag.)
-
Step 4: Thread the booleans. Update
cmd_addarms to pass the real flag values instead of the hard-wiredfalsefrom A3 (e.g.ib::build_card(title, holder, expiry, &kind, number_stdin, cvv_stdin, pin_stdin)?). Destructure the new fields in eachAddKindarm. -
Step 5: Run to verify pass. Run:
cargo test -p relicario-cli --test basic_flowsExpected: PASS (incl. the new case). -
Step 6: Update CLI architecture doc. In
crates/relicario-cli/ARCHITECTURE.md, document the newcommands/item_build.rsmodule (shared builders + edit helpers) and the--*-stdinsecret convention. Citeitem_build.rsfor the secret-resolution rule. -
Step 7: Commit.
git add crates/relicario-cli/src/main.rs crates/relicario-cli/src/commands/add.rs crates/relicario-cli/tests/basic_flows.rs crates/relicario-cli/ARCHITECTURE.md
git commit -m "feat(cli): --*-stdin secret flags for personal add (non-interactive secrets)"
Dev-B — Org Card / Key / Totp parity
Consumes: the Dev-A shared module (crate::commands::item_build).
Produces: the org per-type add/edit dispatch skeleton that Dev-C extends with Document.
Task B1: Extend commands::org::OrgAddKind + run_add for Card/Key/Totp
Files:
- Modify:
crates/relicario-cli/src/commands/org.rs(OrgAddKindenum +build_org_item→ shared builders)
Interfaces produced:
// commands/org.rs
pub(crate) enum OrgAddKind {
Login { title: String, username: Option<String>, url: Option<String>,
password: Option<String>, password_stdin: bool },
SecureNote { title: String, body: Option<String>, body_stdin: bool },
Identity { title: String, full_name: Option<String>, email: 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 added by Dev-C.
}
fn build_org_item(kind: OrgAddKind) -> Result<Item>; // tags set by run_add caller
- Step 1: Replace
build_org_itemto delegate to shared builders. Incrates/relicario-cli/src/commands/org.rs:
use crate::commands::item_build as ib;
fn build_org_item(kind: OrgAddKind) -> Result<Item> {
match kind {
OrgAddKind::Login { title, username, url, password, password_stdin } =>
ib::build_login(title, username, url, password, password_stdin, false, None),
OrgAddKind::SecureNote { title, body, body_stdin } =>
ib::build_secure_note(title, body, body_stdin),
OrgAddKind::Identity { title, full_name, email, phone } =>
ib::build_identity(title, full_name, email, phone, None),
OrgAddKind::Card { title, holder, expiry, kind, number_stdin, cvv_stdin, pin_stdin } =>
ib::build_card(title, holder, expiry, &kind, number_stdin, cvv_stdin, pin_stdin),
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),
}
}
-
Step 2: Update
run_addto set tags after build.run_add's signature staysrun_add(dir, collection, kind: OrgAddKind, tags: Vec<String>); afterlet item = build_org_item(kind)?;addlet mut item = item; item.tags = tags;(or fold into the existing flow). The rest ofrun_add(grant check,save_item, manifest upsert, signed commit) is unchanged. -
Step 3: Build. Run:
cargo build -p relicario-cliExpected: compiles (main.rs dispatch updated in B2 — expect an error there until B2; buildcommands/org.rsin isolation by temporarily leaving the old main.rs mapping is not possible, so do B1+B2 together before building). Proceed to B2, then build. -
Step 4: Commit (with B2). Combined commit at end of B2.
Task B2: Wire main.rs clap surface + dispatch for org Card/Key/Totp
Files:
-
Modify:
crates/relicario-cli/src/main.rs(OrgAddKindclap enum + theOrgCommands::Adddispatch) -
Step 1: Extend the clap
OrgAddKindenum. Add Card/Key/Totp variants mirroring personal flags plus--collection,--title(required), and the--*-stdinflags:
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,
},
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,
},
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,
},
Also add #[arg(long)] password_stdin: bool to the existing clap OrgAddKind::Login and #[arg(long)] body_stdin: bool to SecureNote (and make SecureNote.body Option<String> so it can be omitted for stdin/prompt).
- Step 2: Extend the dispatch mapping. In
main.rs'sOrgCommands::Addmatch, add arms mapping each clap variant tocommands::org::OrgAddKind:
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,
),
Update the existing Login/SecureNote arms to pass the new password_stdin/body_stdin fields.
-
Step 3: Build. Run:
cargo build -p relicario-cliExpected: compiles. -
Step 4: Commit B1+B2.
git add crates/relicario-cli/src/commands/org.rs crates/relicario-cli/src/main.rs
git commit -m "feat(cli/org): org add parity for Card/Key/Totp via shared builders"
Task B3: Convert org edit to per-type interactive dispatch
Files:
- Modify:
crates/relicario-cli/src/commands/org.rs(run_edit) - Modify:
crates/relicario-cli/src/main.rs(OrgCommands::Editargs + dispatch)
Rationale: match the personal edit (interactive, "blank to keep", field-history) by reusing the shared edit_* helpers. The flat --username/--url/... flags are dropped; non-interactive tests drive the prompts via piped stdin + RELICARIO_TEST_ITEM_SECRET.
- Step 1: Rewrite
run_edit. Replace the flat-flag signature withrun_edit(dir: &Path, query: &str, totp_qr: Option<PathBuf>):
pub fn run_edit(dir: &Path, query: &str, totp_qr: Option<std::path::PathBuf>) -> Result<()> {
use relicario_core::time::now_unix;
use relicario_core::ItemCore;
use crate::commands::item_build as ib;
let vault = crate::org_session::open_org_vault(Some(dir))?;
let caller = vault.current_member()?;
let manifest = vault.load_manifest()?;
let visible = manifest.filter_for_member(&caller);
let entry = resolve_org_query(&visible, query)?;
let collection = entry.collection.clone();
let id = entry.id.clone();
crate::org_session::UnlockedOrgVault::ensure_grant(&caller, &collection)?;
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; }
let history = &mut item.field_history;
match &mut item.core {
ItemCore::Login(l) => ib::edit_login(l, history, totp_qr)?,
ItemCore::SecureNote(n) => ib::edit_secure_note(n, history)?,
ItemCore::Identity(i) => ib::edit_identity(i)?,
ItemCore::Card(c) => ib::edit_card(c, history)?,
ItemCore::Key(k) => ib::edit_key(k, history)?,
ItemCore::Document(_) => ib::edit_document_message(),
ItemCore::Totp(t) => ib::edit_totp(t, history)?,
}
item.modified = now_unix();
let item_rel = vault.save_item(&collection, &item)?;
let mut m = vault.load_manifest()?;
upsert_org_entry(&mut m, &item, &collection);
vault.save_manifest(&m)?;
let commit_msg = format!(
"org edit: {} ({})\n\nRelicario-Actor: {} {}\nRelicario-Action: item-edit\nRelicario-Collection: {}\nRelicario-Item: {}",
crate::helpers::sanitize_for_commit(&item.title), 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, &["commit", "-m", &commit_msg], "org edit: git commit")?;
println!("Updated {} ({}) in `{}`", item.title, item.id.as_str(), collection);
Ok(())
}
-
Step 2: Update
OrgCommands::Editclap + dispatch. Inmain.rs, change theEditvariant to{ #[arg(long)] query: String (or positional), #[arg(long)] totp_qr: Option<PathBuf> }and the dispatch tocommands::org::run_edit(&d, &query, totp_qr)?. Remove the dropped--username/--url/--password/--body/--email/--phone/--full_namefields. -
Step 3: Build. Run:
cargo build -p relicario-cliExpected: compiles. -
Step 4: Commit.
git add crates/relicario-cli/src/commands/org.rs crates/relicario-cli/src/main.rs
git commit -m "feat(cli/org): interactive per-type org edit via shared edit helpers"
Task B4: Integration tests — org Card/Key/Totp round-trips
Files:
-
Modify:
crates/relicario-cli/tests/org_items.rs(fixture stdin helper + new tests) -
Step 1: Add a stdin-piping run helper to
OrgFixture.
/// Like `run`, but pipes `stdin_data` into the child's stdin (for --*-stdin
/// secrets and interactive edit prompts).
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()
}
- Step 2: Write the failing Card test. Assumes the fixture already creates a collection
engand grants the owner (reuse the existing helper in this file that the Login/Identity tests use; if it is inline, replicate theorg create-collection+org grantcalls):
#[test]
fn org_add_card_via_stdin_then_get_masks_secret() {
let f = OrgFixture::new();
f.create_collection_and_grant("eng"); // existing helper used by the Login tests
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));
let got = f.run(&["org", "get", "Corp Visa"]);
let stdout = String::from_utf8_lossy(&got.stdout);
assert!(stdout.contains("Corp Visa"));
assert!(stdout.contains("********"), "card number must be masked without --show");
assert!(!stdout.contains("4111111111111111"), "secret leaked without --show");
}
-
Step 3: Run to verify failure. Run:
cargo test -p relicario-cli --test org_items org_add_cardExpected: FAIL —cardsubcommand unknown (until B1/B2 merged). -
Step 4: Add Key + Totp + edit tests. Key (material via stdin →
org getshowsLabel, secret masked), Totp (--secretflag value →org getshows issuer/label), and an edit round-trip usingrun_stdinto drive the interactive prompts (e.g. change a Card holder: pipe\nn\nto keep title, answer holder, decline number change — match the exact prompt order ofedit_card).
#[test]
fn org_add_totp_with_secret_flag_round_trips() {
let f = OrgFixture::new();
f.create_collection_and_grant("eng");
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"]);
assert!(String::from_utf8_lossy(&got.stdout).contains("Issuer: AWS"));
}
-
Step 5: Run all org tests. Run:
cargo test -p relicario-cli --test org_itemsExpected: PASS. -
Step 6: Commit.
git add crates/relicario-cli/tests/org_items.rs
git commit -m "test(cli/org): Card/Key/Totp add/get/edit round-trips via stdin"
Dev-C — Org Document + attachment storage
Consumes: Dev-A build_document; Dev-B's OrgAddKind + run_add/run_edit skeleton in org.rs (rebase on B).
Task C1: Org attachment storage in org_session
Files:
- Modify:
crates/relicario-cli/src/org_session.rs
Interfaces produced:
// org_session.rs
pub const DEFAULT_ORG_ATTACHMENT_MAX_BYTES: u64 = 25 * 1024 * 1024; // mirrors personal default; see note
impl UnlockedOrgVault {
pub fn attachment_path(&self, collection_slug: &str, item_id: &ItemId, att_id: &AttachmentId) -> PathBuf;
pub fn save_attachment(&self, collection_slug: &str, item_id: &ItemId, enc: &EncryptedAttachment) -> Result<String>;
pub fn load_attachment(&self, collection_slug: &str, item_id: &ItemId, att_id: &AttachmentId) -> Result<Zeroizing<Vec<u8>>>;
pub fn remove_item_attachments(&self, collection_slug: &str, item_id: &ItemId) -> Result<()>;
}
Cap value: confirm
25 * 1024 * 1024against the personal default atcrates/relicario-core/src/settings.rs(attachment_caps.per_attachment_max_bytes). Use the personal default's exact value and cite its source line in a doc comment (code-constant-pinning rule).
- Step 1: Write the failing test. Add to
org_session.rsan inline#[cfg(test)]round-trip:
#[cfg(test)]
mod attachment_tests {
use super::*;
use relicario_core::encrypt_attachment;
use tempfile::TempDir;
#[test]
fn attachment_round_trip_collection_scoped() {
let dir = TempDir::new().unwrap();
let vault = UnlockedOrgVault { root: dir.path().to_path_buf(), org_key: Zeroizing::new([7u8; 32]) };
let item_id = ItemId::new();
let enc = encrypt_attachment(b"hello world", &vault.org_key, DEFAULT_ORG_ATTACHMENT_MAX_BYTES).unwrap();
let rel = vault.save_attachment("eng", &item_id, &enc).unwrap();
assert_eq!(rel, format!("attachments/eng/{}/{}.enc", item_id.as_str(), enc.id.as_str()));
let got = vault.load_attachment("eng", &item_id, &enc.id).unwrap();
assert_eq!(got.as_slice(), b"hello world");
vault.remove_item_attachments("eng", &item_id).unwrap();
assert!(vault.load_attachment("eng", &item_id, &enc.id).is_err());
}
}
Verify
ItemId::new()exists (used throughout core); if the constructor differs, build an id the same wayItem::newdoes.
-
Step 2: Run to verify failure. Run:
cargo test -p relicario-cli --lib attachment_round_tripExpected: FAIL — methods/constant not found. -
Step 3: Implement. Add to
org_session.rs(imports:AttachmentId,EncryptedAttachment,decrypt_attachmentfromrelicario_core):
/// Default per-attachment cap for org vaults. Org has no settings.enc, so this
/// mirrors the personal-vault default at crates/relicario-core/src/settings.rs.
pub const DEFAULT_ORG_ATTACHMENT_MAX_BYTES: u64 = 25 * 1024 * 1024;
impl UnlockedOrgVault {
pub fn attachment_path(&self, collection_slug: &str, item_id: &ItemId, att_id: &AttachmentId) -> PathBuf {
self.root.join("attachments").join(collection_slug)
.join(item_id.as_str()).join(format!("{}.enc", att_id.as_str()))
}
pub fn save_attachment(&self, collection_slug: &str, item_id: &ItemId, enc: &EncryptedAttachment) -> Result<String> {
let path = self.attachment_path(collection_slug, item_id, &enc.id);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
}
atomic_write(&path, &enc.bytes)?;
Ok(format!("attachments/{}/{}/{}.enc", collection_slug, item_id.as_str(), enc.id.as_str()))
}
pub fn load_attachment(&self, collection_slug: &str, item_id: &ItemId, att_id: &AttachmentId) -> Result<Zeroizing<Vec<u8>>> {
let path = self.attachment_path(collection_slug, item_id, att_id);
let bytes = fs::read(&path).with_context(|| format!("read attachment {}", path.display()))?;
Ok(relicario_core::decrypt_attachment(&bytes, &self.org_key)?)
}
pub fn remove_item_attachments(&self, collection_slug: &str, item_id: &ItemId) -> Result<()> {
let dir = self.root.join("attachments").join(collection_slug).join(item_id.as_str());
match fs::remove_dir_all(&dir) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(anyhow::Error::from(e).context(format!("remove {}", dir.display()))),
}
}
}
-
Step 4: Run to verify pass. Run:
cargo test -p relicario-cli --lib attachment_round_tripExpected: PASS. -
Step 5: Commit.
git add crates/relicario-cli/src/org_session.rs
git commit -m "feat(cli/org): collection-scoped attachment storage + default cap"
Task C2: Org add document + commit the attachment path
Files:
-
Modify:
crates/relicario-cli/src/commands/org.rs(OrgAddKind::Document,run_adddocument handling) -
Modify:
crates/relicario-cli/src/main.rs(clapOrgAddKind::Document+ dispatch) -
Step 1: Add the Document variant + builder call. In
commands/org.rs, addDocument { title: String, file: PathBuf }toOrgAddKind. Because Document must write an attachment blob and stage its path, special-case it inrun_addrather thanbuild_org_item:
pub fn run_add(dir: &Path, collection: &str, kind: OrgAddKind, tags: Vec<String>) -> Result<()> {
use crate::org_session::{UnlockedOrgVault, DEFAULT_ORG_ATTACHMENT_MAX_BYTES};
let vault = crate::org_session::open_org_vault(Some(dir))?;
let caller = vault.current_member()?;
let collections = vault.load_collections()?;
if !collections.contains_slug(collection) {
anyhow::bail!("collection `{collection}` does not exist — create it with `relicario org create-collection`");
}
UnlockedOrgVault::ensure_grant(&caller, collection)?;
// Build the item; Document also yields an encrypted attachment to persist.
let (mut item, attachment_rel): (relicario_core::Item, Option<String>) = match kind {
OrgAddKind::Document { title, file } => {
let (item, enc) = crate::commands::item_build::build_document(
title, file, vault.key(), DEFAULT_ORG_ATTACHMENT_MAX_BYTES)?;
let rel = vault.save_attachment(collection, &item.id, &enc)?;
(item, Some(rel))
}
other => (build_org_item(other)?, None),
};
item.tags = tags;
let item_rel = vault.save_item(collection, &item)?;
let mut manifest = vault.load_manifest()?;
upsert_org_entry(&mut manifest, &item, collection);
vault.save_manifest(&manifest)?;
let subject = format!("org add: {} ({})", crate::helpers::sanitize_for_commit(&item.title), item.id.as_str());
let commit_msg = format!(
"{subject}\n\nRelicario-Actor: {} {}\nRelicario-Action: item-create\nRelicario-Collection: {}\nRelicario-Item: {}",
caller.display_name, caller.member_id.as_str(), collection, item.id.as_str());
let mut add_args: Vec<&str> = vec!["add", &item_rel, "manifest.enc"];
if let Some(ref rel) = attachment_rel { add_args.insert(1, rel); }
crate::org_session::org_git_run(&vault.root, &add_args, "org add: git add")?;
crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "org add: git commit")?;
println!("Added {} ({}) to `{}`", item.title, item.id.as_str(), collection);
Ok(())
}
Note:
build_org_itemloses itstagsresponsibility (Dev-B already moved tag-setting intorun_add). Confirm the B1 version matchesfn build_org_item(kind) -> Result<Item>with no tags.
- Step 2: Add clap
OrgAddKind::Document+ dispatch inmain.rs:
Document {
#[arg(long)] collection: String,
#[arg(long)] title: String,
#[arg(long)] file: std::path::PathBuf,
#[arg(long, value_delimiter = ',')] tags: Vec<String>,
},
Dispatch arm:
OrgAddKind::Document { collection, title, file, tags } => (
collection,
commands::org::OrgAddKind::Document { title, file },
tags,
),
-
Step 3: Build. Run:
cargo build -p relicario-cliExpected: compiles. -
Step 4: Commit.
git add crates/relicario-cli/src/commands/org.rs crates/relicario-cli/src/main.rs
git commit -m "feat(cli/org): org add document with collection-scoped attachment"
Task C3: Purge removes attachments + Document edit via --file
Files:
-
Modify:
crates/relicario-cli/src/commands/org.rs(run_purge, Document edit) -
Step 1: Purge removes the attachment directory. In
run_purge(hard delete), aftervault.remove_item(&collection, &id)?addvault.remove_item_attachments(&collection, &id)?;and stage the deletions in the commit (the attachment files are removed from the worktree;git add -A-style staging of the item + attachment paths). Match the existingrun_purgecommit-staging pattern; if it uses explicit paths, add the attachment dir. -
Step 2: Document edit via
--file. Sinceedit_document_message()tells personal users to useattach/extract(which org lacks), give org Document edit a real path: extendrun_editso a Document item with a provided--filereplaces the primary attachment. Simplest: add an optionalfile: Option<PathBuf>parameter torun_edit; in theItemCore::Document(d)arm, iffileisSome, build a newEncryptedAttachment, remove the old attachment dir, save the new blob, and updated.filename/d.mime_type/d.primary_attachment+item.attachments. IffileisNone, callib::edit_document_message()(a no-op note). Wire--fileintoOrgCommands::Editclap + dispatch.
ItemCore::Document(d) => {
if let Some(path) = file {
let bytes = std::fs::read(&path).with_context(|| format!("read {}", path.display()))?;
let enc = relicario_core::encrypt_attachment(&bytes, vault.key(),
crate::org_session::DEFAULT_ORG_ATTACHMENT_MAX_BYTES)?;
vault.remove_item_attachments(&collection, &id)?;
let _rel = vault.save_attachment(&collection, &id, &enc)?;
let filename = path.file_name().unwrap().to_string_lossy().into_owned();
d.mime_type = crate::parse::guess_mime(&filename);
d.primary_attachment = enc.id.clone();
item.attachments = vec![relicario_core::AttachmentRef {
id: enc.id, filename: filename.clone(), mime_type: d.mime_type.clone(),
size: bytes.len() as u64, created: now_unix(),
}];
d.filename = filename;
} else {
ib::edit_document_message();
}
}
When
--filereplaces the attachment, the commit must stage the new attachment path too — extend theorg_git_run "add"arg list inrun_editto include the new attachment rel path (capture it fromsave_attachment).
-
Step 3: Build. Run:
cargo build -p relicario-cliExpected: compiles. -
Step 4: Commit.
git add crates/relicario-cli/src/commands/org.rs crates/relicario-cli/src/main.rs
git commit -m "feat(cli/org): org document edit via --file + purge removes attachments"
Task C4: Integration tests — org Document + docs
Files:
-
Modify:
crates/relicario-cli/tests/org_items.rs -
Modify:
docs/FORMATS.md -
Step 1: Write the failing Document test.
#[test]
fn org_add_document_stores_collection_scoped_attachment() {
let f = OrgFixture::new();
f.create_collection_and_grant("eng");
let src = f.vault.path().join("note.txt"); // any temp file
std::fs::write(&src, b"secret memo").unwrap();
let out = f.run(&["org", "add", "document", "--collection", "eng",
"--title", "Q3 Memo", "--file", src.to_str().unwrap()]);
assert!(out.status.success(), "add doc: {}", String::from_utf8_lossy(&out.stderr));
// An encrypted attachment exists under attachments/eng/<item>/<att>.enc.
let att_root = f.vault.path().join("attachments").join("eng");
assert!(att_root.exists(), "attachment dir missing");
let got = f.run(&["org", "get", "Q3 Memo"]);
let stdout = String::from_utf8_lossy(&got.stdout);
assert!(stdout.contains("Filename: note.txt"));
}
-
Step 2: Run to verify failure. Run:
cargo test -p relicario-cli --test org_items org_add_documentExpected: FAIL —documentsubcommand unknown (until C2 merged). -
Step 3: Add a purge-removes-attachment test.
#[test]
fn org_purge_document_removes_attachment_dir() {
let f = OrgFixture::new();
f.create_collection_and_grant("eng");
let src = f.vault.path().join("d.bin");
std::fs::write(&src, b"bytes").unwrap();
f.run(&["org", "add", "document", "--collection", "eng", "--title", "Doc", "--file", src.to_str().unwrap()]);
f.run(&["org", "rm", "Doc"]); // trash
let out = f.run(&["org", "purge", "Doc"]); // hard delete
assert!(out.status.success(), "purge: {}", String::from_utf8_lossy(&out.stderr));
let att_root = f.vault.path().join("attachments").join("eng");
let empty = !att_root.exists() || std::fs::read_dir(&att_root).map(|mut d| d.next().is_none()).unwrap_or(true);
assert!(empty, "attachment dir should be gone after purge");
}
-
Step 4: Run all org tests. Run:
cargo test -p relicario-cli --test org_itemsExpected: PASS. -
Step 5: Update FORMATS.md. Add the org attachment layout (
attachments/<slug>/<item-id>/<att-id>.enc) and the default cap constant, citingcrates/relicario-cli/src/org_session.rs(DEFAULT_ORG_ATTACHMENT_MAX_BYTES). -
Step 6: Commit.
git add crates/relicario-cli/tests/org_items.rs docs/FORMATS.md
git commit -m "test(cli/org): org document storage round-trip + purge; FORMATS attachment layout"
Dev-D — Server hook: grant-scope attachment paths
Independent — no dependency on A/B/C.
Task D1: Extend classify_path for attachments
Files:
-
Modify:
crates/relicario-server/src/lib.rs -
Modify:
crates/relicario-server/tests/org_hook.rs -
Modify:
crates/relicario-server/Cargo.toml(version bump) -
Modify:
docs/SECURITY.md -
Step 1: Write the failing tests. Append to
crates/relicario-server/tests/org_hook.rs:
#[test]
fn attachment_path_is_collection_scoped() {
assert_eq!(
classify_path("attachments/prod/a1b2c3d4e5f6a1b2/0011223344556677.enc"),
PathClass::Item { collection: "prod".to_string() }
);
}
#[test]
fn attachment_wrong_segment_count_is_rejected() {
assert_eq!(
classify_path("attachments/prod/onlytwo.enc"),
PathClass::Rejected("attachments path must be attachments/<slug>/<item-id>/<att-id>.enc".to_string())
);
}
#[test]
fn attachment_empty_or_dotted_slug_is_rejected() {
assert!(matches!(classify_path("attachments//item/att.enc"), PathClass::Rejected(_)));
assert!(matches!(classify_path("attachments/../item/att.enc"), PathClass::Rejected(_)));
}
-
Step 2: Run to verify failure. Run:
cargo test -p relicario-server --test org_hook attachment_Expected: FAIL — attachments classify asUnrestricted. -
Step 3: Implement the attachments branch. In
crates/relicario-server/src/lib.rs::classify_path, before the finalPathClass::Unrestricted, add:
if let Some(rest) = path.strip_prefix("attachments/") {
// Expect exactly: <slug>/<item-id>/<att-id>.enc → three segments.
let segments: Vec<&str> = rest.split('/').collect();
if segments.len() != 3 {
return PathClass::Rejected(
"attachments path must be attachments/<slug>/<item-id>/<att-id>.enc".to_string());
}
let slug = segments[0];
if slug.is_empty() {
return PathClass::Rejected("empty collection slug in attachments path".to_string());
}
if slug.contains('.') {
return PathClass::Rejected(format!("invalid collection slug: {:?}", slug));
}
return PathClass::Item { collection: slug.to_string() };
}
Update the PathClass::Item doc comment to mention both items/<slug>/<id>.enc and attachments/<slug>/<item-id>/<att-id>.enc.
-
Step 4: Run to verify pass. Run:
cargo test -p relicario-serverExpected: PASS (existing + 3 new tests). The hook'smain.rsauthorization loop already handlesPathClass::Item { collection }(grant + slug-existence) — nomain.rschange needed; attachment writes are now grant-scoped automatically. -
Step 5: Version bump + SECURITY doc. Bump
versionincrates/relicario-server/Cargo.toml(patch bump). Indocs/SECURITY.md, note that org attachment writes are now grant-scoped (previouslyUnrestricted), and that deploying this requires rebuilding the pre-receive hook on the server. -
Step 6: Commit.
git add crates/relicario-server/src/lib.rs crates/relicario-server/tests/org_hook.rs crates/relicario-server/Cargo.toml docs/SECURITY.md
git commit -m "feat(server): grant-scope org attachment write paths in pre-receive hook"
Integration & release (after all four streams merge)
- Full test sweep. Run
cargo test(all crates) +cargo build -p relicario-wasm --target wasm32-unknown-unknown. Expected: green. - Type-check the extension.
cd extension && npm run build:all(the extension is unchanged, but verify nothing broke the workspace). Expected: clean. - STATUS.md / ROADMAP.md / CHANGELOG.md. Mark org item-type parity (Card/Key/Document/Totp) + grant-scoped attachments landed; move them to shipped; note extension org writes (Plan B-2) still deferred.
- Release. Cut v0.8.1 via the release workflow (
Workflow({name:"release", args:{action:"release", release:"v0.8.1"}})). The release notes MUST call out the coordinatedrelicario-serverredeploy (rebuild the pre-receive hook) so attachment writes are grant-scoped in production.
Self-Review (plan author)
Spec coverage:
- Shared builder module +
--*-stdin(spec §Design.1, .2) → Dev-A (A1–A4). ✓ - Org Card/Key/Totp add+edit parity (spec §Design.2, .3) → Dev-B (B1–B4). ✓
- Org Document + attachment storage + cap (spec §Design.4) → Dev-C (C1–C4). ✓
- Hook grant-scoping (spec §Design.5) → Dev-D (D1). ✓
- Tests (spec §Design.6) → B4, C4, D1 + integration sweep. ✓
- Living docs (spec §Design.7) → A4 (cli ARCHITECTURE), C4 (FORMATS), D1 (SECURITY), release task (STATUS/ROADMAP/CHANGELOG). ✓
- Out-of-scope (extension writes, phase-2, configurable cap) → not planned. ✓
Placeholder scan: No "TBD"/"similar to Task N"/bare "add error handling". Concrete code in every code step. Two explicit verify-against-source notes (cap value vs settings.rs; ItemId::new() constructor) are flagged as verification steps, not placeholders.
Type consistency: OrgAddKind (commands layer) used identically in B1/B2/C2; build_document returns (Item, EncryptedAttachment) consumed in A3 (personal) and C2 (org); DEFAULT_ORG_ATTACHMENT_MAX_BYTES defined in C1, used in C2/C3; run_edit(dir, query, totp_qr[, file]) — note: C3 adds a file param to the B3 signature, so C3 must update the B3 run_edit signature and its main.rs dispatch together (called out in C3 Step 2).