Compare commits

..

1 Commits

Author SHA1 Message Date
adlee-was-taken
415d8ed9ef docs(cli): document v0.8.1 org item-type parity surface in ARCHITECTURE.md
- org.rs bullet: full Card/Key/Document/Totp org add/edit parity via the
  shared item_build builders + edit helpers; interactive per-type edit;
  --*-stdin secret convention; purge removes attachments. Replaces the stale
  'Login/SecureNote/Identity only' + flag-driven-edit + deferred text.
- org_session.rs bullet: collection-scoped attachment storage (attachment_path/
  save/load/remove + DEFAULT_ORG_ATTACHMENT_MAX_BYTES).
- main.rs bullet: OrgCommands + OrgAddKind clap surface.

Source-line citations pinned per the code-constant-pinning discipline.
2026-06-20 22:00:29 -04:00
3 changed files with 67 additions and 47 deletions

View File

@@ -24,7 +24,10 @@ under `src/commands/`. Each source file has one job.
- **`src/main.rs`** (`main.rs:1-492`) — clap surface and the flat dispatcher. - **`src/main.rs`** (`main.rs:1-492`) — clap surface and the flat dispatcher.
Owns the top-level `Cli` / `Commands` enum and every subcommand enum Owns the top-level `Cli` / `Commands` enum and every subcommand enum
(`AddKind`, `TrashAction`, `SettingsAction`, `BackupAction`, `ImportAction`, (`AddKind`, `TrashAction`, `SettingsAction`, `BackupAction`, `ImportAction`,
`DeviceAction`, `RecoveryQrCmd`). `main()` is a single `match` that `DeviceAction`, `RecoveryQrCmd`), plus the org clap surface `OrgCommands`
(`main.rs:448`) and `OrgAddKind` (`main.rs:556`) — the latter's Card / Key /
Document / Totp variants carry `--collection` and the `--*-stdin` secret flags.
`main()` is a single `match` that
delegates each variant to `commands::<verb>::cmd_<verb>(...)`. Also owns the delegates each variant to `commands::<verb>::cmd_<verb>(...)`. Also owns the
three test-only env-var hooks (`test_passphrase_override`, three test-only env-var hooks (`test_passphrase_override`,
`test_item_secret_override`, `test_backup_passphrase_override`) — each is `test_item_secret_override`, `test_backup_passphrase_override`) — each is
@@ -94,7 +97,14 @@ under `src/commands/`. Each source file has one job.
(`items/<collection-slug>/<id>.enc` — the leading slug is what the pre-receive (`items/<collection-slug>/<id>.enc` — the leading slug is what the pre-receive
hook authorizes against, never decrypting), fingerprint-based member matching hook authorizes against, never decrypting), fingerprint-based member matching
(`relicario_core::fingerprint`, tolerant of OpenSSH whitespace/comment (`relicario_core::fingerprint`, tolerant of OpenSSH whitespace/comment
differences), `atomic_write`, and `org_git_run`. Note `org_git_run` runs differences), `atomic_write`, and `org_git_run`. As of v0.8.1 it also owns
**collection-scoped attachment storage**`attachment_path` /
`save_attachment` / `load_attachment` / `remove_item_attachments`
(`org_session.rs:125-157`) at layout
`attachments/<collection-slug>/<item-id>/<att-id>.enc` (the same leading slug
the pre-receive hook authorizes against as for `item_path`), capped
per-attachment by `DEFAULT_ORG_ATTACHMENT_MAX_BYTES` (10 MiB,
`org_session.rs:20`). Note `org_git_run` runs
**bare git** — unlike `helpers::git_run` it does NOT inject **bare git** — unlike `helpers::git_run` it does NOT inject
`commit.gpgsign=false`, because org commits MUST be signed (the hook verifies `commit.gpgsign=false`, because org commits MUST be signed (the hook verifies
every commit's signature); signing config is established by every commit's signature); signing config is established by
@@ -111,19 +121,38 @@ under `src/commands/`. Each source file has one job.
concurrent-rotation abort), `transfer-ownership`, `delete-org`, `status` / concurrent-rotation abort), `transfer-ownership`, `delete-org`, `status` /
`audit` (verified-signer attribution + `TAMPERED` flag). `audit` (verified-signer attribution + `TAMPERED` flag).
*Item CRUD (7):* `org add` creates typed items via `OrgAddKind` *Item CRUD (7):* full item-type parity with the personal vault (v0.8.1).
(`commands/org.rs:749`) — **Login / SecureNote / Identity only**; Card / `org add` creates **all seven types** (Login / SecureNote / Identity / Card /
SshKey / Document / Totp creation is a deferred follow-up. `get` / `list` can Key / Document / Totp) via `OrgAddKind` (`commands/org.rs:751`); each arm
display any item type if present. `org get <query> [--show]` masks secrets delegates to the shared `item_build::build_*` builders through `build_org_item`
unless `--show`; `org list [--trashed]` filters by the caller's collection (`commands/org.rs:799`), and `run_add` (`commands/org.rs:823`) sets tags
grants; `org edit <query>` is flag-driven (blank flags keep current values); post-build. Document is special-cased in `run_add` (`commands/org.rs:839`): its
`org rm` soft-deletes, `org restore` undoes, `org purge` permanently removes builder also yields an `EncryptedAttachment` that is written via
the encrypted blob. All item ops are collection-scoped and grant-enforced. The `save_attachment` and git-staged before the signed commit. Single-line secrets
audit trail emits `item-create` / `item-update` / `item-delete` / (card number/CVV/PIN, TOTP secret, login password) accept a `--*-stdin` flag;
`item-restore` / `item-purge`. multiline secrets (Key material, SecureNote body) read stdin to EOF — the same
`resolve_secret_line` / `resolve_secret_multiline` convention as personal `add`
(`commands/item_build.rs`).
Deferred: Card / SshKey / Document / Totp `org add` / `edit` parity; `org edit <query>` (`run_edit`, `commands/org.rs:1004`) is **interactive
extension org reads and writes (Dev-D). per-type** as of v0.8.1 (it was flag-driven before): it prompts Title, then
dispatches on `&mut item.core` to the shared `item_build::edit_*` helpers
("blank keeps current", field-history capture via `push_history`), mirroring
personal `cmd_edit`. `--totp-qr` sets a Login TOTP from a QR image; `--file`
replaces a Document's primary attachment (`commands/org.rs:1039`, rejected for
non-Document items at `commands/org.rs:1018`). The edit commit carries
`Relicario-Action: item-update`.
`org get <query> [--show]` masks every secret unless `--show`; `org list
[--trashed]` filters by the caller's collection grants; `org rm` soft-deletes,
`org restore` undoes, `org purge` (`run_purge`, `commands/org.rs:1164`)
permanently removes the encrypted blob **and** the item's attachment directory
(`remove_item_attachments`, `commands/org.rs:1173`). All item ops are
collection-scoped and grant-enforced (`filter_for_member` over the manifest +
`ensure_grant` before any load/mutate). The audit trail emits `item-create` /
`item-update` / `item-delete` / `item-restore` / `item-purge`.
Deferred: extension org reads and writes (Plan B-2 / phase 2).
- **`src/helpers.rs`** (`helpers.rs:1-101`) — pure, no-state plumbing: - **`src/helpers.rs`** (`helpers.rs:1-101`) — pure, no-state plumbing:
`find_vault_dir_from` (`helpers.rs:14-28`) walks up parent directories `find_vault_dir_from` (`helpers.rs:14-28`) walks up parent directories

View File

@@ -3,7 +3,7 @@
//! (`commands/org.rs`). Centralizing it keeps the two surfaces from drifting. //! (`commands/org.rs`). Centralizing it keeps the two surfaces from drifting.
use std::collections::HashMap; use std::collections::HashMap;
use std::path::{Path, PathBuf}; use std::path::PathBuf;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use zeroize::Zeroizing; use zeroize::Zeroizing;
@@ -255,39 +255,23 @@ pub(crate) fn build_totp(
}))) })))
} }
/// Read a file and encrypt it as an attachment under `key`, deriving its display
/// metadata. The plaintext is held in a `Zeroizing` buffer so it is wiped after
/// encryption. Returns the encrypted blob plus (filename, mime_type, size).
pub(crate) fn encrypt_document_file(
path: &Path,
key: &Zeroizing<[u8; 32]>,
max_bytes: u64,
) -> Result<(EncryptedAttachment, String, String, u64)> {
use relicario_core::encrypt_attachment;
let bytes = Zeroizing::new(
std::fs::read(path).with_context(|| format!("failed to read {}", path.display()))?,
);
let enc = encrypt_attachment(&bytes, key, max_bytes)?;
let filename = path
.file_name()
.ok_or_else(|| anyhow::anyhow!("file path has no filename: {}", path.display()))?
.to_string_lossy()
.into_owned();
let mime_type = crate::parse::guess_mime(&filename);
Ok((enc, filename, mime_type, bytes.len() as u64))
}
pub(crate) fn build_document( pub(crate) fn build_document(
title: String, file: PathBuf, key: &Zeroizing<[u8; 32]>, max_bytes: u64, title: String, file: PathBuf, key: &Zeroizing<[u8; 32]>, max_bytes: u64,
) -> Result<(Item, EncryptedAttachment)> { ) -> Result<(Item, EncryptedAttachment)> {
use relicario_core::item_types::DocumentCore; use relicario_core::item_types::DocumentCore;
use relicario_core::AttachmentRef; use relicario_core::{encrypt_attachment, AttachmentRef};
let (enc, filename, mime_type, size) = encrypt_document_file(&file, key, max_bytes)?; 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 { let mut item = Item::new(title, ItemCore::Document(DocumentCore {
filename: filename.clone(), mime_type: mime_type.clone(), primary_attachment: enc.id.clone(), filename: filename.clone(), mime_type: mime_type.clone(), primary_attachment: primary_attachment.clone(),
})); }));
item.attachments.push(AttachmentRef { item.attachments.push(AttachmentRef {
id: enc.id.clone(), filename, mime_type, size, created: item.created, id: primary_attachment, filename, mime_type, size: bytes.len() as u64, created: item.created,
}); });
Ok((item, enc)) Ok((item, enc))
} }

View File

@@ -1038,18 +1038,25 @@ pub fn run_edit(dir: &Path, query: &str, totp_qr: Option<std::path::PathBuf>, fi
ItemCore::Key(k) => ib::edit_key(k, history)?, ItemCore::Key(k) => ib::edit_key(k, history)?,
ItemCore::Document(d) => { ItemCore::Document(d) => {
if let Some(path) = &file { if let Some(path) = &file {
let (enc, filename, mime_type, size) = ib::encrypt_document_file( let bytes = std::fs::read(path)
path, vault.key(), crate::org_session::DEFAULT_ORG_ATTACHMENT_MAX_BYTES)?; .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)?; vault.remove_item_attachments(&collection, &id)?;
let rel = vault.save_attachment(&collection, &id, &enc)?; let rel = vault.save_attachment(&collection, &id, &enc)?;
d.filename = filename.clone(); let filename = path
d.mime_type = mime_type.clone(); .file_name()
.ok_or_else(|| anyhow::anyhow!("file path has no filename: {}", path.display()))?
.to_string_lossy()
.into_owned();
d.mime_type = crate::parse::guess_mime(&filename);
d.primary_attachment = enc.id.clone(); d.primary_attachment = enc.id.clone();
d.filename = filename.clone();
new_doc_attachments = Some(vec![relicario_core::AttachmentRef { new_doc_attachments = Some(vec![relicario_core::AttachmentRef {
id: enc.id, id: enc.id,
filename, filename,
mime_type, mime_type: d.mime_type.clone(),
size, size: bytes.len() as u64,
created: now_unix(), created: now_unix(),
}]); }]);
doc_attachment_rel = Some(rel); doc_attachment_rel = Some(rel);