//! Relicario CLI — the platform layer for the Relicario password manager. //! //! See module docs for the unlock flow and vault layout. mod device; mod gitea; mod helpers; mod session; use std::path::PathBuf; use anyhow::{bail, Context, Result}; use clap::{CommandFactory, Parser, Subcommand}; use clap_complete::{generate, Shell}; #[derive(Parser)] #[command( name = "relicario", version, about = "Relicario — git-backed password manager with reference-image two-factor unlock" )] struct Cli { #[command(subcommand)] command: Commands, } #[derive(Subcommand)] enum Commands { /// Initialize a new vault in the current directory. Init { /// Carrier JPEG to embed the secret into. #[arg(long)] image: PathBuf, /// Output path for the reference image (gitignored). #[arg(long, default_value = "reference.jpg")] output: PathBuf, }, /// Add a new item. Type-specific flags populate the core; missing fields /// are prompted for interactively. Add { #[command(subcommand)] kind: AddKind, }, /// Print an item. Secrets are masked by default; pass --show to reveal. Get { /// Item id or case-insensitive title substring. query: String, /// Print secret field values in plaintext. #[arg(long)] show: bool, /// Copy the primary secret (Login.password, Card.number, etc.) to clipboard. #[arg(long)] copy: bool, }, /// List items. List { #[arg(long)] r#type: Option, #[arg(long)] group: Option, #[arg(long)] tag: Option, #[arg(long)] trashed: bool, }, /// Edit an item interactively. Edit { query: String, /// Decode an `otpauth://` QR image to set the TOTP secret (login items only). #[arg(long, value_name = "PATH")] totp_qr: Option, }, /// View captured field history for an item. Values are masked by /// default; pass `--show` to reveal them. History { query: String, #[arg(long)] show: bool, /// Filter to a single field (matches against the synthetic key, /// e.g. `login_password`, `card_number`, `totp_secret`). #[arg(long)] field: Option, }, /// Soft-delete an item (moves to trash; reversible via `restore`). Rm { query: String }, /// Restore a soft-deleted item. Restore { query: String }, /// Permanently purge an item (and its attachments). Purge { query: String }, /// Trash operations. Trash { #[command(subcommand)] action: TrashAction, }, /// Backup operations: pack and unpack `.relbak` archives. Backup { #[command(subcommand)] action: BackupAction, }, /// Import items from another password manager into the unlocked vault. Import { #[command(subcommand)] action: ImportAction, }, /// Attach a file to an item. Attach { query: String, file: PathBuf }, /// List attachments on an item. Attachments { query: String }, /// Extract an attachment to disk. Extract { query: String, aid: String, #[arg(long)] out: Option, }, /// Remove an individual attachment from an item (deletes the encrypted /// blob and updates the item + manifest). Use `purge` to drop the entire /// item and all its attachments at once. Detach { query: String, aid: String }, /// Generate a password or passphrase. When run inside an initialized /// vault, falls back to `settings generator-defaults` for unspecified /// flags; outside a vault, uses built-in defaults (length 20, safe /// symbol set, 5 BIP39 words, space separator). Generate { #[arg(long)] length: Option, #[arg(long)] bip39: bool, #[arg(long)] words: Option, #[arg(long)] symbols: Option, /// Separator for BIP39 words. #[arg(long)] separator: Option, }, /// View or change vault settings. Settings { #[command(subcommand)] action: SettingsAction, }, /// Sync with the git remote (pull --rebase + push). Sync, /// Print a summary of the vault: items, attachments, last commit. Status, /// Lock the vault (no-op in CLI; present for UX parity with the extension). Lock, /// Emit a shell completion script for the given shell. /// /// For `--group ` autocomplete, the bash/zsh/fish scripts read /// the plaintext `${RELICARIO_VAULT}/.relicario/groups.cache` file, /// which the CLI refreshes on every manifest read. Set /// `RELICARIO_NO_GROUPS_CACHE=1` to opt out of the cache (completion /// will fall back to no value enumeration). /// /// Pipe stdout to your shell's completion location (e.g. /// `relicario completions bash > /etc/bash_completion.d/relicario`). Completions { #[arg(value_enum)] shell: Shell, }, /// Rate a passphrase with zxcvbn — prints score (0-4) and estimated /// guesses. Informational only; does not gate vault operations. /// /// Pass `-` as the argument to read one line from stdin instead, which /// keeps the passphrase out of shell history. Rate { /// Passphrase to score, or `-` to read from stdin. passphrase: String, }, /// Manage registered devices (signing keys + deploy keys). Device { #[command(subcommand)] action: DeviceAction, }, } #[derive(Subcommand)] enum AddKind { Login { #[arg(long)] title: Option, #[arg(long)] username: Option, #[arg(long)] url: Option, /// Prompt for password (vs reading from stdin or --password). #[arg(long)] password_prompt: bool, #[arg(long)] password: Option, #[arg(long)] group: Option, #[arg(long, value_delimiter = ',')] tags: Vec, #[arg(long)] favorite: bool, /// Decode an `otpauth://` QR image to fill the TOTP secret. #[arg(long, value_name = "PATH")] totp_qr: Option, }, SecureNote { #[arg(long)] title: Option, #[arg(long)] body_prompt: bool, #[arg(long)] group: Option, #[arg(long, value_delimiter = ',')] tags: Vec, }, Identity { #[arg(long)] title: Option, #[arg(long)] full_name: Option, #[arg(long)] email: Option, #[arg(long)] phone: Option, #[arg(long)] date_of_birth: Option, #[arg(long)] group: Option, #[arg(long, value_delimiter = ',')] tags: Vec, }, Card { #[arg(long)] title: Option, #[arg(long)] holder: Option, #[arg(long)] expiry: Option, // MM/YYYY #[arg(long, default_value = "credit")] kind: String, #[arg(long)] group: Option, #[arg(long, value_delimiter = ',')] tags: Vec, }, Key { #[arg(long)] title: Option, #[arg(long)] label: Option, #[arg(long)] algorithm: Option, #[arg(long)] group: Option, #[arg(long, value_delimiter = ',')] tags: Vec, }, Document { #[arg(long)] title: Option, #[arg(long)] file: PathBuf, #[arg(long)] group: Option, #[arg(long, value_delimiter = ',')] tags: Vec, }, Totp { #[arg(long)] title: Option, #[arg(long)] issuer: Option, #[arg(long)] label: Option, #[arg(long)] secret: Option, // base32 #[arg(long, default_value = "30")] period: u32, #[arg(long, default_value = "6")] digits: u8, #[arg(long, default_value = "sha1")] algorithm: String, #[arg(long)] group: Option, #[arg(long, value_delimiter = ',')] tags: Vec, }, } #[derive(Subcommand)] enum TrashAction { /// List trashed items. List, /// Purge every trashed item past its retention window. Empty, } #[derive(Subcommand)] enum SettingsAction { /// Show current settings as JSON. Show, /// Set trash retention (e.g., --days 30 or --forever). TrashRetention { #[arg(long)] days: Option, #[arg(long)] forever: bool, }, /// Set field history retention. HistoryRetention { #[arg(long)] last_n: Option, #[arg(long)] days: Option, #[arg(long)] forever: bool, }, /// Set per-attachment max size in bytes. AttachmentCap { #[arg(long)] per_attachment_max_bytes: Option, #[arg(long)] per_item_max_count: Option, #[arg(long)] per_vault_soft_cap_bytes: Option, #[arg(long)] per_vault_hard_cap_bytes: Option, }, /// Update the default password / passphrase generator settings used by /// `relicario generate` when run inside this vault. Pass `--bip39` or /// `--random` to switch mode; per-attribute flags update fields of the /// chosen mode. GeneratorDefaults { /// Switch the default mode to random-character password. #[arg(long, conflicts_with = "bip39")] random: bool, /// Switch the default mode to BIP39 passphrase. #[arg(long, conflicts_with = "random")] bip39: bool, /// Random mode: total password length. #[arg(long)] length: Option, /// BIP39 mode: number of words. #[arg(long)] words: Option, /// Random mode: symbol charset (`safe`, `extended`, or a custom literal). #[arg(long)] symbols: Option, /// BIP39 mode: word separator. #[arg(long)] separator: Option, }, } #[derive(Subcommand)] enum BackupAction { /// Pack the local vault into a single encrypted `.relbak` file. /// Backup passphrase is independent of the vault passphrase. Export { /// Output `.relbak` path. out: PathBuf, /// Bundle the reference JPEG into the encrypted envelope. #[arg(long)] include_image: bool, /// Override the reference image path (defaults to the vault's /// `reference.jpg` or `RELICARIO_IMAGE`). #[arg(long)] image: Option, /// Skip bundling `.git/` history. #[arg(long)] no_history: bool, }, /// Unpack a `.relbak` file into a fresh vault directory. Restore { /// Input `.relbak` path. input: PathBuf, /// Target directory (must NOT already contain `.relicario/`). /// Defaults to the current directory. #[arg(default_value = ".")] target: PathBuf, }, } #[derive(Subcommand)] enum ImportAction { /// Import a LastPass CSV export into the unlocked vault. /// Each row creates a new item with a freshly-minted ID; title /// collisions are kept (no dedup). Failed rows are skipped and /// reported on stderr. Lastpass { /// Path to the LastPass-format CSV export. csv: PathBuf, }, } #[derive(Subcommand)] enum DeviceAction { /// Register this machine as a new device. /// /// Generates two ed25519 keypairs: one for signing commits, one for push /// access (deploy key). The deploy public key is registered via the Gitea /// API. Both private keys are stored locally in /// `~/.config/relicario/devices//`. The vault's `.relicario/devices.json` /// is updated and committed. /// /// Required environment variables (or flags): /// RELICARIO_GITEA_URL — e.g. https://git.example.com /// RELICARIO_GITEA_TOKEN — personal access token with repo write access /// RELICARIO_GITEA_OWNER — repository owner /// RELICARIO_GITEA_REPO — repository name Add { /// Human-readable name for this device (e.g. "laptop-2026"). #[arg(long)] name: String, /// Gitea API base URL (overrides RELICARIO_GITEA_URL). #[arg(long)] gitea_url: Option, /// Gitea personal access token (overrides RELICARIO_GITEA_TOKEN). #[arg(long)] gitea_token: Option, /// Gitea repository owner (overrides RELICARIO_GITEA_OWNER). #[arg(long)] owner: Option, /// Gitea repository name (overrides RELICARIO_GITEA_REPO). #[arg(long)] repo: Option, /// Skip Gitea API registration (useful when the remote is not Gitea). #[arg(long)] no_gitea: bool, }, /// Revoke a registered device. /// /// Removes the device from `devices.json`, adds it to `revoked.json`, /// deletes the deploy key from Gitea, and commits the change. Revoke { /// Name of the device to revoke. #[arg(long)] name: String, }, /// List registered devices. List, } fn main() -> Result<()> { let cli = Cli::parse(); match cli.command { Commands::Init { image, output } => cmd_init(image, output), Commands::Add { kind } => cmd_add(kind), Commands::Get { query, show, copy } => cmd_get(query, show, copy), Commands::List { r#type, group, tag, trashed } => cmd_list(r#type, group, tag, trashed), Commands::Edit { query, totp_qr } => cmd_edit(query, totp_qr), Commands::History { query, show, field } => cmd_history(query, show, field), Commands::Rm { query } => cmd_rm(query), Commands::Restore { query } => cmd_restore(query), Commands::Purge { query } => cmd_purge(query), Commands::Trash { action } => cmd_trash(action), Commands::Backup { action } => cmd_backup(action), Commands::Import { action } => cmd_import(action), Commands::Attach { query, file } => cmd_attach(query, file), Commands::Attachments { query } => cmd_attachments(query), Commands::Extract { query, aid, out } => cmd_extract(query, aid, out), Commands::Detach { query, aid } => cmd_detach(query, aid), Commands::Generate { length, bip39, words, symbols, separator } => { cmd_generate(length, bip39, words, symbols, separator) } Commands::Settings { action } => cmd_settings(action), Commands::Sync => cmd_sync(), Commands::Status => cmd_status(), Commands::Lock => { eprintln!("no cached session to lock"); Ok(()) } Commands::Completions { shell } => { let mut cmd = Cli::command(); generate(shell, &mut cmd, "relicario", &mut std::io::stdout()); Ok(()) } Commands::Rate { passphrase } => cmd_rate(passphrase), Commands::Device { action } => cmd_device(action), } } /// Collect all non-empty group names from the manifest and write them to the /// plaintext `groups.cache` file so shell completion can enumerate `--group` /// candidates without prompting for the vault passphrase. /// /// Failures are silently swallowed — a missing cache is merely a UX degradation, /// not a correctness problem. fn refresh_groups_cache(vault_dir: &std::path::Path, manifest: &relicario_core::Manifest) { let mut set = std::collections::BTreeSet::::new(); for entry in manifest.items.values() { if let Some(g) = entry.group.as_ref() { if !g.is_empty() { set.insert(g.clone()); } } } let _ = helpers::write_groups_cache(vault_dir, &set); } /// Check for test passphrase override (debug builds only; stripped from release). #[cfg(debug_assertions)] pub(crate) fn test_passphrase_override() -> Option { std::env::var("RELICARIO_TEST_PASSPHRASE").ok() } #[cfg(not(debug_assertions))] pub(crate) fn test_passphrase_override() -> Option { None } /// Check for test item secret override (debug builds only; stripped from release). #[cfg(debug_assertions)] fn test_item_secret_override() -> Option { std::env::var("RELICARIO_TEST_ITEM_SECRET").ok() } #[cfg(not(debug_assertions))] fn test_item_secret_override() -> Option { None } /// Check for test backup passphrase override (debug builds only; stripped from release). #[cfg(debug_assertions)] fn test_backup_passphrase_override() -> Option { std::env::var("RELICARIO_TEST_BACKUP_PASSPHRASE").ok() } #[cfg(not(debug_assertions))] fn test_backup_passphrase_override() -> Option { None } /// `rpassword::prompt_password` wrapper that honours `RELICARIO_TEST_ITEM_SECRET` /// for integration-test use (rpassword reads /dev/tty by default, which is /// unavailable in assert_cmd-spawned children). fn prompt_secret(label: &str) -> Result { if let Some(s) = test_item_secret_override() { return Ok(s); } rpassword::prompt_password(label).map_err(Into::into) } fn cmd_init(image: PathBuf, output: PathBuf) -> Result<()> { use std::fs; use rand::{rngs::OsRng, RngCore}; use relicario_core::{ derive_master_key, encrypt_manifest, encrypt_settings, imgsecret, validate_passphrase_strength, KdfParams, Manifest, VaultSettings, }; use zeroize::Zeroizing; let root = std::env::current_dir()?; let relicario_dir = root.join(".relicario"); if relicario_dir.exists() { anyhow::bail!(".relicario/ already exists in {}", root.display()); } // Passphrase with strength gate (audit H3). // RELICARIO_TEST_PASSPHRASE is a test-only escape hatch that bypasses the // TTY prompt so integration tests can run without a real TTY. let passphrase = if let Some(p) = test_passphrase_override() { Zeroizing::new(p) } else { Zeroizing::new(rpassword::prompt_password("Choose a passphrase: ")?) }; let confirm = if test_passphrase_override().is_some() { passphrase.clone() } else { Zeroizing::new(rpassword::prompt_password("Confirm passphrase: ")?) }; if passphrase.as_str() != confirm.as_str() { anyhow::bail!("passphrases do not match"); } if let Err(e) = validate_passphrase_strength(&passphrase) { anyhow::bail!("{}. Choose a longer or more entropic phrase.", e); } // Image secret: 32 random bytes, embedded in the carrier. let image_secret = { let mut buf = Zeroizing::new([0u8; 32]); OsRng.fill_bytes(buf.as_mut_slice()); buf }; let carrier = fs::read(&image) .with_context(|| format!("failed to read carrier image {}", image.display()))?; let stego = imgsecret::embed(&carrier, &*image_secret)?; fs::write(&output, &stego) .with_context(|| format!("failed to write reference image {}", output.display()))?; // Vault salt + KDF params. let mut salt = [0u8; 32]; OsRng.fill_bytes(&mut salt); let params = KdfParams { argon2_m: 65536, argon2_t: 3, argon2_p: 4 }; // Derive master key, then persist an empty Manifest + default VaultSettings. let master_key = derive_master_key(passphrase.as_bytes(), &*image_secret, &salt, ¶ms)?; fs::create_dir_all(&relicario_dir)?; fs::create_dir_all(root.join("items"))?; fs::create_dir_all(root.join("attachments"))?; fs::write(relicario_dir.join("salt"), salt)?; fs::write( relicario_dir.join("params.json"), serde_json::to_string_pretty(&ParamsFile { format_version: 2, kdf: ParamsKdf { algorithm: "argon2id-v0x13".into(), argon2_m: params.argon2_m, argon2_t: params.argon2_t, argon2_p: params.argon2_p, }, aead: "xchacha20poly1305".into(), salt_path: ".relicario/salt".into(), })?, )?; let manifest = Manifest::new(); fs::write(root.join("manifest.enc"), encrypt_manifest(&manifest, &master_key)?)?; let settings = VaultSettings::default(); fs::write(root.join("settings.enc"), encrypt_settings(&settings, &master_key)?)?; // .gitignore excludes the reference image. let fname = output.file_name() .ok_or_else(|| anyhow::anyhow!("output path has no filename: {}", output.display()))? .to_string_lossy(); let gitignore = format!("{fname}\n"); fs::write(root.join(".gitignore"), gitignore)?; // git init + initial commit via hardened wrapper. let status = crate::helpers::git_command(&root, &["init"]).status()?; if !status.success() { anyhow::bail!("git init failed"); } let _ = crate::helpers::git_command(&root, &[ "add", ".gitignore", ".relicario/params.json", ".relicario/salt", "manifest.enc", "settings.enc", ]).status()?; let status = crate::helpers::git_command(&root, &[ "commit", "-m", "init: new Relicario vault (format v2)", ]).status()?; if !status.success() { anyhow::bail!("git commit failed"); } eprintln!("Vault initialized at {}", root.display()); eprintln!("Reference image: {}", output.display()); eprintln!(" \u{2192} back this file up somewhere safe; it is your second factor."); Ok(()) } fn cmd_add(kind: AddKind) -> Result<()> { let vault = crate::session::UnlockedVault::unlock_interactive()?; let mut manifest = vault.load_manifest()?; let item = match kind { AddKind::Login { title, username, url, password_prompt, password, group, tags, favorite, totp_qr } => build_login_item(title, username, url, password_prompt, password, group, tags, favorite, totp_qr)?, AddKind::SecureNote { title, body_prompt, group, tags } => build_secure_note_item(title, body_prompt, group, tags)?, AddKind::Identity { title, full_name, email, phone, date_of_birth, group, tags } => build_identity_item(title, full_name, email, phone, date_of_birth, group, tags)?, AddKind::Card { title, holder, expiry, kind, group, tags } => build_card_item(title, holder, expiry, kind, group, tags)?, AddKind::Key { title, label, algorithm, group, tags } => build_key_item(title, label, algorithm, group, tags)?, AddKind::Document { title, file, group, tags } => build_document_item(&vault, title, file, group, tags)?, AddKind::Totp { title, issuer, label, secret, period, digits, algorithm, group, tags } => build_totp_item(title, issuer, label, secret, period, digits, algorithm, group, tags)?, }; vault.save_item(&item)?; manifest.upsert(&item); vault.save_manifest(&manifest)?; refresh_groups_cache(vault.root(), &manifest); let mut paths: Vec = vec![ format!("items/{}.enc", item.id.as_str()), "manifest.enc".into(), ]; for att in &item.attachments { paths.push(format!("attachments/{}/{}.enc", item.id.as_str(), att.id.as_str())); } let path_refs: Vec<&str> = paths.iter().map(|s| s.as_str()).collect(); commit_paths(&vault, &format!("add: {} ({})", crate::helpers::sanitize_for_commit(&item.title), item.id.as_str()), &path_refs)?; eprintln!("Added: {} (id={})", item.title, item.id.as_str()); Ok(()) } // --- Per-type item builders, one per AddKind variant. Each returns a // fully-populated Item; cmd_add handles the common save/manifest/commit // wrap-up. Document is the only builder that needs the unlocked vault // (for attachment-cap settings + writing the encrypted blob alongside // the item). fn build_login_item( title: Option, username: Option, url: Option, password_prompt: bool, password: Option, group: Option, tags: Vec, favorite: bool, totp_qr: Option, ) -> Result { use relicario_core::item_types::{LoginCore, TotpAlgorithm, TotpConfig, TotpKind}; use relicario_core::{Item, ItemCore}; use zeroize::Zeroizing; let title = title.map(Ok).unwrap_or_else(|| prompt("Title"))?; let username = username.or_else(|| prompt_optional("Username").ok().flatten()); let url = url.or_else(|| prompt_optional("URL").ok().flatten()); 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, body_prompt: bool, group: Option, tags: Vec, ) -> Result { use relicario_core::item_types::SecureNoteCore; use relicario_core::{Item, ItemCore}; use zeroize::Zeroizing; let title = title.map(Ok).unwrap_or_else(|| prompt("Title"))?; 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, full_name: Option, email: Option, phone: Option, date_of_birth: Option, group: Option, tags: Vec, ) -> Result { use relicario_core::item_types::IdentityCore; use relicario_core::{Item, ItemCore}; let title = title.map(Ok).unwrap_or_else(|| prompt("Title"))?; 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, holder: Option, expiry: Option, kind: String, group: Option, tags: Vec, ) -> Result { use relicario_core::item_types::{CardCore, CardKind}; use relicario_core::{Item, ItemCore}; use zeroize::Zeroizing; let title = title.map(Ok).unwrap_or_else(|| prompt("Title"))?; 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, label: Option, algorithm: Option, group: Option, tags: Vec, ) -> Result { use relicario_core::item_types::KeyCore; use relicario_core::{Item, ItemCore}; use zeroize::Zeroizing; let title = title.map(Ok).unwrap_or_else(|| prompt("Title"))?; 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, file: PathBuf, group: Option, tags: Vec, ) -> Result { use relicario_core::item_types::DocumentCore; use relicario_core::{encrypt_attachment, AttachmentRef, Item, ItemCore}; use std::fs; let title = title.map(Ok).unwrap_or_else(|| prompt("Title"))?; 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) } fn build_totp_item( title: Option, issuer: Option, label: Option, secret: Option, period: u32, digits: u8, algorithm: String, group: Option, tags: Vec, ) -> Result { use relicario_core::item_types::{TotpAlgorithm, TotpConfig, TotpCore, TotpKind}; use relicario_core::{Item, ItemCore}; use zeroize::Zeroizing; let title = title.map(Ok).unwrap_or_else(|| prompt("Title"))?; 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) } fn prompt(label: &str) -> Result { eprint!("{label}: "); std::io::Write::flush(&mut std::io::stderr())?; let mut s = String::new(); std::io::stdin().read_line(&mut s)?; let trimmed = s.trim().to_string(); if trimmed.is_empty() { anyhow::bail!("{label} required"); } Ok(trimmed) } fn prompt_optional(label: &str) -> Result> { eprint!("{label} (leave blank to skip): "); std::io::Write::flush(&mut std::io::stderr())?; let mut s = String::new(); std::io::stdin().read_line(&mut s)?; let trimmed = s.trim().to_string(); Ok(if trimmed.is_empty() { None } else { Some(trimmed) }) } fn parse_month_year(s: &str) -> Result { // Accepts MM/YYYY or MM-YYYY or MM/YY. let (m_str, y_str) = s.split_once(|c: char| c == '/' || c == '-') .ok_or_else(|| anyhow::anyhow!("expected MM/YYYY"))?; let month: u8 = m_str.parse().context("invalid month")?; let year: u16 = if y_str.len() == 2 { 2000 + y_str.parse::().context("invalid 2-digit year")? } else { y_str.parse().context("invalid year")? }; Ok(relicario_core::MonthYear { month, year }) } fn guess_mime(filename: &str) -> String { let lower = filename.to_ascii_lowercase(); match lower.rsplit_once('.').map(|(_, ext)| ext).unwrap_or("") { "pdf" => "application/pdf", "png" => "image/png", "jpg" | "jpeg" => "image/jpeg", "txt" => "text/plain", "json" => "application/json", _ => "application/octet-stream", }.to_string() } fn base32_decode_lenient(s: &str) -> Result> { let cleaned: String = s.chars() .filter(|c| !c.is_whitespace()) .collect::() .to_ascii_uppercase() .trim_end_matches('=') .to_string(); let padded = { let rem = cleaned.len() % 8; if rem == 0 { cleaned } else { format!("{}{}", cleaned, "=".repeat(8 - rem)) } }; data_encoding::BASE32.decode(padded.as_bytes()) .map_err(|e| anyhow::anyhow!("invalid base32: {e}")) } fn commit_paths(vault: &crate::session::UnlockedVault, message: &str, paths: &[&str]) -> Result<()> { let mut args: Vec<&str> = vec!["add"]; args.extend_from_slice(paths); let status = crate::helpers::git_command(vault.root(), &args).status()?; if !status.success() { anyhow::bail!("git add failed"); } let status = crate::helpers::git_command(vault.root(), &["commit", "-m", message]).status()?; if !status.success() { anyhow::bail!("git commit failed"); } Ok(()) } fn cmd_get(query: String, show: bool, copy: bool) -> Result<()> { use relicario_core::ItemCore; use zeroize::Zeroizing; let vault = crate::session::UnlockedVault::unlock_interactive()?; let manifest = vault.load_manifest()?; refresh_groups_cache(vault.root(), &manifest); let entry = resolve_query(&manifest, &query)?; let item = vault.load_item(&entry.id)?; println!("ID: {}", item.id.as_str()); println!("Title: {}", item.title); println!("Type: {:?}", item.r#type); if let Some(g) = &item.group { println!("Group: {g}"); } if !item.tags.is_empty() { println!("Tags: {}", item.tags.join(", ")); } println!("Created: {}", crate::helpers::iso8601(item.created)); println!("Modified: {}", crate::helpers::iso8601(item.modified)); if let Some(t) = item.trashed_at { println!("Trashed: {}", crate::helpers::iso8601(t)); } println!(); let primary_secret: Option> = match &item.core { ItemCore::Login(l) => { if let Some(u) = &l.username { println!("Username: {u}"); } if let Some(u) = &l.url { println!("URL: {u}"); } if let Some(t) = &l.totp { if show { println!("TOTP: {}", data_encoding::BASE32.encode(&*t.secret)); } else { println!("TOTP: **** (use --show to reveal)"); } } if let Some(p) = &l.password { Some(p.clone()) } else { None } } ItemCore::SecureNote(n) => { if show { println!("Body:\n{}", n.body.as_str()); } else { println!("Body: ********"); } None } ItemCore::Identity(i) => { if let Some(v) = &i.full_name { println!("Name: {v}"); } if let Some(v) = &i.email { println!("Email: {v}"); } if let Some(v) = &i.phone { println!("Phone: {v}"); } if let Some(v) = &i.date_of_birth { println!("DOB: {v}"); } None } ItemCore::Card(c) => { if let Some(h) = &c.holder { println!("Holder: {h}"); } if let Some(e) = &c.expiry { println!("Expiry: {:02}/{}", e.month, e.year); } println!("Kind: {:?}", c.kind); c.number.clone() } ItemCore::Key(k) => { if let Some(l) = &k.label { println!("Label: {l}"); } if let Some(a) = &k.algorithm { println!("Algo: {a}"); } if let Some(pk) = &k.public_key { println!("Pubkey: {pk}"); } Some(k.key_material.clone()) } ItemCore::Document(d) => { println!("Filename: {}", d.filename); println!("MIME: {}", d.mime_type); None } ItemCore::Totp(t) => { if let Some(i) = &t.issuer { println!("Issuer: {i}"); } if let Some(l) = &t.label { println!("Label: {l}"); } println!("Period: {}s", t.config.period_seconds); println!("Digits: {}", t.config.digits); None } }; if let Some(secret) = primary_secret { if show { println!("Secret: {}", secret.as_str()); } else { println!("Secret: ******** (use --show to reveal, --copy to clipboard)"); } if copy { copy_to_clipboard_then_clear(&secret)?; eprintln!("Copied to clipboard (auto-clears in 30s)."); } } Ok(()) } fn resolve_query<'a>( manifest: &'a relicario_core::Manifest, query: &str, ) -> Result<&'a relicario_core::ManifestEntry> { if let Some(entry) = manifest.items.values().find(|e| e.id.as_str() == query) { return Ok(entry); } let hits: Vec<_> = manifest.search(query); match hits.len() { 0 => anyhow::bail!("no item matches `{query}`"), 1 => Ok(hits[0]), _ => { let titles: Vec<&str> = hits.iter().map(|e| e.title.as_str()).collect(); anyhow::bail!("ambiguous — {} matches: {}", hits.len(), titles.join(", ")) } } } fn copy_to_clipboard_then_clear(secret: &zeroize::Zeroizing) -> Result<()> { use arboard::Clipboard; let mut cb = Clipboard::new().context("failed to access clipboard")?; cb.set_text(secret.as_str().to_string()).context("failed to write clipboard")?; let cleared_copy = zeroize::Zeroizing::new(secret.as_str().to_owned()); // Unconditional clear (audit M6): spawn a detached thread that waits 30s // and then rewrites the clipboard with empty string. Even if the user // copies something else in the interim, we still overwrite once. std::thread::spawn(move || { std::thread::sleep(std::time::Duration::from_secs(30)); if let Ok(mut cb) = Clipboard::new() { let _ = cb.set_text(String::new()); drop(cleared_copy); // zeroize the detached copy } }); Ok(()) } fn cmd_list( type_filter: Option, group_filter: Option, tag_filter: Option, trashed: bool, ) -> Result<()> { use relicario_core::ItemType; let vault = crate::session::UnlockedVault::unlock_interactive()?; let manifest = vault.load_manifest()?; refresh_groups_cache(vault.root(), &manifest); let parsed_type: Option = match type_filter.as_deref() { None => None, Some("login") => Some(ItemType::Login), Some("secure_note") | Some("note") => Some(ItemType::SecureNote), Some("identity") => Some(ItemType::Identity), Some("card") => Some(ItemType::Card), Some("key") => Some(ItemType::Key), Some("document") => Some(ItemType::Document), Some("totp") => Some(ItemType::Totp), Some(other) => anyhow::bail!("unknown type filter: {other}"), }; let mut entries: Vec<_> = manifest.items.values() .filter(|e| { if trashed { e.trashed_at.is_some() } else { e.trashed_at.is_none() } }) .filter(|e| match parsed_type { Some(t) => e.r#type == t, None => true, }) .filter(|e| group_filter.as_ref().map_or(true, |g| e.group.as_deref() == Some(g.as_str()))) .filter(|e| tag_filter.as_ref().map_or(true, |t| e.tags.iter().any(|x| x == t))) .collect(); entries.sort_by(|a, b| a.title.to_lowercase().cmp(&b.title.to_lowercase())); if entries.is_empty() { eprintln!("(no items match)"); return Ok(()); } println!("{:<16} {:<14} {:<6} {}", "ID", "TYPE", "FAV", "TITLE"); for e in entries { let fav = if e.favorite { " *" } else { "" }; println!("{:<16} {:<14} {:<6} {}", e.id.as_str(), format!("{:?}", e.r#type), fav, e.title); } Ok(()) } fn cmd_edit(query: String, totp_qr: Option) -> Result<()> { use relicario_core::time::now_unix; use relicario_core::ItemCore; let vault = crate::session::UnlockedVault::unlock_interactive()?; let mut manifest = vault.load_manifest()?; let entry = resolve_query(&manifest, &query)?; let id = entry.id.clone(); let _ = entry; let mut item = vault.load_item(&id)?; eprintln!("Editing: {} ({}) — leave a prompt blank to keep the current value.", item.title, item.id.as_str()); if let Some(v) = prompt_keep("Title", &item.title)? { item.title = v; } if let Some(v) = prompt_keep_opt("Group", item.group.as_deref())? { item.group = Some(v); } if let Some(v) = prompt_keep_opt("Tags (comma-separated)", Some(&item.tags.join(",")))? { item.tags = v.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect(); } let history = &mut item.field_history; match &mut item.core { ItemCore::Login(l) => edit_login(l, history, totp_qr)?, ItemCore::SecureNote(n) => edit_secure_note(n, history)?, ItemCore::Identity(i) => edit_identity(i)?, ItemCore::Card(c) => edit_card(c, history)?, ItemCore::Key(k) => edit_key(k, history)?, ItemCore::Document(_) => edit_document_message(), ItemCore::Totp(t) => edit_totp(t, history)?, } item.modified = now_unix(); vault.save_item(&item)?; manifest.upsert(&item); vault.save_manifest(&manifest)?; refresh_groups_cache(vault.root(), &manifest); commit_paths(&vault, &format!("edit: {} ({})", crate::helpers::sanitize_for_commit(&item.title), item.id.as_str()), &[&format!("items/{}.enc", item.id.as_str()), "manifest.enc"])?; eprintln!("Updated {}", item.id.as_str()); Ok(()) } // --- Per-type edit handlers. Each mutates its core slice in place; the ones // that touch history-tracked fields take the item's field_history map so // they can record the prior value alongside the change. type FieldHistory = std::collections::HashMap< relicario_core::FieldId, Vec, >; fn edit_login( l: &mut relicario_core::item_types::LoginCore, history: &mut FieldHistory, totp_qr: Option, ) -> 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 prompt_keep(label: &str, current: &str) -> Result> { eprint!("{label} [{current}]: "); std::io::Write::flush(&mut std::io::stderr())?; let mut s = String::new(); std::io::stdin().read_line(&mut s)?; let trimmed = s.trim().to_string(); Ok(if trimmed.is_empty() { None } else { Some(trimmed) }) } fn prompt_keep_opt(label: &str, current: Option<&str>) -> Result> { let display = current.unwrap_or("(none)"); eprint!("{label} [{display}]: "); std::io::Write::flush(&mut std::io::stderr())?; let mut s = String::new(); std::io::stdin().read_line(&mut s)?; let trimmed = s.trim().to_string(); Ok(if trimmed.is_empty() { None } else { Some(trimmed) }) } fn prompt_yesno(label: &str) -> Result { eprint!("{label} [y/N] "); std::io::Write::flush(&mut std::io::stderr())?; let mut s = String::new(); std::io::stdin().read_line(&mut s)?; Ok(matches!(s.trim().to_ascii_lowercase().as_str(), "y" | "yes")) } fn push_history( history: &mut std::collections::HashMap>, synthetic_key: &str, old_value: zeroize::Zeroizing, ) { 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(), }); } fn cmd_history(query: String, show: bool, field: Option) -> Result<()> { let vault = crate::session::UnlockedVault::unlock_interactive()?; let manifest = vault.load_manifest()?; let entry = resolve_query(&manifest, &query)?; let item = vault.load_item(&entry.id)?; println!("History for {} ({})", item.title, item.id.as_str()); println!(); // Filter and sort the field-id keys so output is deterministic. let mut keys: Vec<&relicario_core::FieldId> = item.field_history.keys().collect(); keys.sort_by(|a, b| a.0.cmp(&b.0)); let mut printed_any = false; for fid in keys { let display_name = fid.0.strip_prefix("core:").unwrap_or(&fid.0); if let Some(filter) = &field { if display_name != filter && fid.0 != *filter { continue; } } let entries = &item.field_history[fid]; if entries.is_empty() { continue; } printed_any = true; println!("{display_name} ({} {})", entries.len(), if entries.len() == 1 { "entry" } else { "entries" }); for (i, e) in entries.iter().enumerate() { let ts = crate::helpers::iso8601(e.replaced_at); if show { println!(" [{i}] {ts} {}", e.value.as_str()); } else { println!(" [{i}] {ts} ********"); } } println!(); } if !printed_any { if field.is_some() { println!("no history for the requested field"); } else { println!("no history captured for this item"); } } else if !show { println!("(use --show to reveal values)"); } Ok(()) } fn cmd_rm(query: String) -> Result<()> { let vault = crate::session::UnlockedVault::unlock_interactive()?; let mut manifest = vault.load_manifest()?; let entry = resolve_query(&manifest, &query)?; let id = entry.id.clone(); let _ = entry; let mut item = vault.load_item(&id)?; item.soft_delete(); vault.save_item(&item)?; manifest.upsert(&item); vault.save_manifest(&manifest)?; refresh_groups_cache(vault.root(), &manifest); commit_paths(&vault, &format!("trash: {} ({})", crate::helpers::sanitize_for_commit(&item.title), item.id.as_str()), &[&format!("items/{}.enc", item.id.as_str()), "manifest.enc"])?; eprintln!("Moved to trash: {}", item.title); Ok(()) } fn cmd_restore(query: String) -> Result<()> { let vault = crate::session::UnlockedVault::unlock_interactive()?; let mut manifest = vault.load_manifest()?; let entry = resolve_query(&manifest, &query)?; let id = entry.id.clone(); let _ = entry; let mut item = vault.load_item(&id)?; item.restore(); vault.save_item(&item)?; manifest.upsert(&item); vault.save_manifest(&manifest)?; refresh_groups_cache(vault.root(), &manifest); commit_paths(&vault, &format!("restore: {} ({})", crate::helpers::sanitize_for_commit(&item.title), item.id.as_str()), &[&format!("items/{}.enc", item.id.as_str()), "manifest.enc"])?; eprintln!("Restored: {}", item.title); Ok(()) } /// Inner purge: assumes vault is already unlocked and manifest is loaded. /// Caller is responsible for saving the manifest and committing afterwards. fn purge_item( vault: &crate::session::UnlockedVault, manifest: &mut relicario_core::Manifest, id: &relicario_core::ItemId, title: &str, ) -> Result<()> { use std::fs; let item_path = vault.item_path(id); if item_path.exists() { fs::remove_file(&item_path)?; } let att_dir = vault.root().join("attachments").join(id.as_str()); if att_dir.exists() { fs::remove_dir_all(&att_dir)?; } manifest.remove(id); let _ = crate::helpers::git_command(vault.root(), &["rm", "-rf", "--ignore-unmatch", &format!("items/{}.enc", id.as_str()), &format!("attachments/{}", id.as_str()), ]).status()?; // Note: caller adds+commits manifest.enc after processing all purges. eprintln!("Purged: {title}"); Ok(()) } fn cmd_purge(query: String) -> Result<()> { let vault = crate::session::UnlockedVault::unlock_interactive()?; let mut manifest = vault.load_manifest()?; let entry = resolve_query(&manifest, &query)?; let id = entry.id.clone(); let title = entry.title.clone(); let _ = entry; purge_item(&vault, &mut manifest, &id, &title)?; vault.save_manifest(&manifest)?; refresh_groups_cache(vault.root(), &manifest); let status = crate::helpers::git_command(vault.root(), &["add", "manifest.enc"]).status()?; if !status.success() { anyhow::bail!("git add manifest.enc failed"); } let status = crate::helpers::git_command(vault.root(), &["commit", "-m", &format!("purge: {} ({})", title, id.as_str())]).status()?; if !status.success() { anyhow::bail!("git commit failed"); } Ok(()) } fn cmd_trash(action: TrashAction) -> Result<()> { match action { TrashAction::List => cmd_list(None, None, None, true), TrashAction::Empty => cmd_trash_empty(), } } fn cmd_backup(action: BackupAction) -> Result<()> { match action { BackupAction::Export { out, include_image, image, no_history } => { cmd_backup_export(out, include_image, image, no_history) } BackupAction::Restore { input, target } => cmd_backup_restore(input, target), } } fn cmd_backup_export( out: PathBuf, include_image: bool, image: Option, no_history: bool, ) -> Result<()> { use std::fs; use relicario_core::{backup, validate_passphrase_strength}; use zeroize::Zeroizing; let root = crate::helpers::vault_dir()?; // Backup passphrase — prompt twice, gate on zxcvbn (audit H3). let passphrase = if let Some(p) = test_backup_passphrase_override() { Zeroizing::new(p) } else { Zeroizing::new(rpassword::prompt_password("Backup passphrase: ")?) }; let confirm = if test_backup_passphrase_override().is_some() { passphrase.clone() } else { Zeroizing::new(rpassword::prompt_password("Confirm passphrase: ")?) }; if passphrase.as_str() != confirm.as_str() { anyhow::bail!("passphrases do not match"); } if let Err(e) = validate_passphrase_strength(&passphrase) { anyhow::bail!("backup {}. Choose a longer or more entropic phrase.", e); } // Read everything from disk that the envelope needs. let salt = fs::read(root.join(".relicario").join("salt")) .with_context(|| "failed to read .relicario/salt")?; let params_json = fs::read_to_string(root.join(".relicario").join("params.json")) .with_context(|| "failed to read .relicario/params.json")?; // devices.json was removed in the B1 security audit fix; fall back to // an empty array so backups of post-B1 vaults still pack cleanly. // Task 12 will remove the devices field from the backup format entirely. let devices_json = fs::read_to_string(root.join(".relicario").join("devices.json")) .unwrap_or_else(|_| "[]".to_string()); let manifest_enc = fs::read(root.join("manifest.enc")) .with_context(|| "failed to read manifest.enc")?; let settings_enc = fs::read(root.join("settings.enc")) .with_context(|| "failed to read settings.enc")?; // Items. let mut item_files = Vec::new(); let items_dir = root.join("items"); if items_dir.is_dir() { for entry in fs::read_dir(&items_dir)? { let p = entry?.path(); if p.extension().and_then(|s| s.to_str()) != Some("enc") { continue; } let id = p.file_stem() .and_then(|s| s.to_str()) .ok_or_else(|| anyhow::anyhow!("bad item filename: {}", p.display()))? .to_string(); let bytes = fs::read(&p)?; item_files.push((id, bytes)); } } // Attachments. Layout: attachments//.enc let mut attach_files = Vec::new(); let attach_dir = root.join("attachments"); if attach_dir.is_dir() { for entry in fs::read_dir(&attach_dir)? { let item_dir = entry?.path(); if !item_dir.is_dir() { continue; } let item_id = item_dir.file_name() .and_then(|s| s.to_str()) .ok_or_else(|| anyhow::anyhow!("bad attachment dir: {}", item_dir.display()))? .to_string(); for sub in fs::read_dir(&item_dir)? { let p = sub?.path(); if p.extension().and_then(|s| s.to_str()) != Some("enc") { continue; } let aid = p.file_stem() .and_then(|s| s.to_str()) .ok_or_else(|| anyhow::anyhow!("bad attachment filename: {}", p.display()))? .to_string(); let bytes = fs::read(&p)?; attach_files.push((item_id.clone(), aid, bytes)); } } } // Optional reference image. let image_bytes = if include_image { let path = match image { Some(p) => p, None => crate::session::get_image_path()?, }; Some(fs::read(&path) .with_context(|| format!("failed to read reference image {}", path.display()))?) } else { None }; // Optional .git/ tar. let git_archive = if no_history { None } else { Some(tar_directory(&root.join(".git"))?) }; let items_refs: Vec = item_files.iter() .map(|(id, bytes)| backup::BackupItem { id: id.clone(), ciphertext: bytes }) .collect(); let attach_refs: Vec = attach_files.iter() .map(|(iid, aid, bytes)| backup::BackupAttachment { item_id: iid.clone(), attachment_id: aid.clone(), ciphertext: bytes, }) .collect(); let input = backup::BackupInput { salt: &salt, params_json: ¶ms_json, devices_json: &devices_json, manifest_enc: &manifest_enc, settings_enc: &settings_enc, items: items_refs, attachments: attach_refs, reference_jpg: image_bytes.as_deref(), git_archive: git_archive.as_deref(), }; let bytes = backup::pack_backup(input, &passphrase)?; // atomic_write via the existing pattern: write `.tmp`, rename. let tmp = { let mut t = out.as_os_str().to_owned(); t.push(".tmp"); PathBuf::from(t) }; fs::write(&tmp, &bytes) .with_context(|| format!("failed to write {}", tmp.display()))?; fs::rename(&tmp, &out) .with_context(|| format!("failed to rename {}", out.display()))?; // Marker file for `cmd_status`. Format: ISO-8601 UTC line. let now_iso = crate::helpers::iso8601(relicario_core::now_unix()); fs::write(root.join(".relicario").join("last_backup"), format!("{now_iso}\n"))?; let mib = (bytes.len() as f64) / (1024.0 * 1024.0); eprintln!( "Wrote {} ({:.2} MiB). Delete after restore is verified.", out.display(), mib ); Ok(()) } /// Tar a directory into an in-memory `Vec`. Used for `.git/` bundling. fn tar_directory(dir: &std::path::Path) -> Result> { let mut buf = Vec::new(); { let mut builder = tar::Builder::new(&mut buf); builder.append_dir_all(".", dir) .with_context(|| format!("failed to tar {}", dir.display()))?; builder.finish().with_context(|| "failed to finalize git tar")?; } Ok(buf) } fn cmd_backup_restore(input: PathBuf, target: PathBuf) -> Result<()> { use std::fs; use relicario_core::backup; use relicario_core::{ItemId, AttachmentId}; use zeroize::Zeroizing; let target = if target.is_absolute() { target } else { std::env::current_dir()?.join(&target) }; if target.join(".relicario").exists() { anyhow::bail!( "target dir already contains a Relicario vault; restore refuses to overwrite — use an empty directory: {}", target.display() ); } fs::create_dir_all(&target) .with_context(|| format!("failed to create target {}", target.display()))?; // Read input file. let bytes = fs::read(&input) .with_context(|| format!("failed to read backup file {}", input.display()))?; // Backup passphrase prompt. let passphrase = if let Some(p) = test_backup_passphrase_override() { Zeroizing::new(p) } else { Zeroizing::new(rpassword::prompt_password("Backup passphrase: ")?) }; let unpacked = backup::unpack_backup(&bytes, &passphrase) .map_err(|e| match e { relicario_core::RelicarioError::Decrypt => anyhow::anyhow!("wrong backup passphrase, or the file is corrupt"), other => anyhow::anyhow!(other), })?; // Write vault layout. let relicario_dir = target.join(".relicario"); fs::create_dir_all(&relicario_dir)?; fs::create_dir_all(target.join("items"))?; fs::create_dir_all(target.join("attachments"))?; fs::write(relicario_dir.join("salt"), unpacked.salt)?; fs::write(relicario_dir.join("params.json"), &unpacked.params_json)?; fs::write(relicario_dir.join("devices.json"), &unpacked.devices_json)?; fs::write(target.join("manifest.enc"), &unpacked.manifest_enc)?; fs::write(target.join("settings.enc"), &unpacked.settings_enc)?; for item in &unpacked.items { let item_id = ItemId(item.id.clone()); if !item_id.is_valid() { anyhow::bail!("invalid item ID in backup: {} (path traversal blocked)", item.id); } fs::write(target.join("items").join(format!("{}.enc", item.id)), &item.ciphertext)?; } for a in &unpacked.attachments { let item_id = ItemId(a.item_id.clone()); let att_id = AttachmentId(a.attachment_id.clone()); if !item_id.is_valid() || !att_id.is_valid() { anyhow::bail!("invalid attachment ID in backup (path traversal blocked)"); } let dir = target.join("attachments").join(&a.item_id); fs::create_dir_all(&dir)?; fs::write(dir.join(format!("{}.enc", a.attachment_id)), &a.ciphertext)?; } // Reference image (if present). if let Some(jpg) = &unpacked.reference_jpg { let path = target.join("reference.jpg"); fs::write(&path, jpg) .with_context(|| format!("failed to write reference image {}", path.display()))?; } // .git/ history. if let Some(tar_bytes) = &unpacked.git_archive { // Cap: 100× the compressed bundle size, or 1 GiB, whichever is lower. let cap = std::cmp::min( (tar_bytes.len() as u64).saturating_mul(100), relicario_core::DEFAULT_MAX_UNCOMPRESSED, ); let entries = relicario_core::safe_unpack_git_archive(tar_bytes, cap) .with_context(|| "failed to safely unpack .git/ archive")?; let git_dir = target.join(".git"); for (rel_path, body) in entries { let dest = git_dir.join(&rel_path); // Paranoid OS-level check even after textual validation in core. if !dest.starts_with(&git_dir) { anyhow::bail!( "tar entry {} resolved outside .git/ (path traversal blocked)", rel_path.display() ); } if let Some(parent) = dest.parent() { fs::create_dir_all(parent).with_context(|| { format!("create parent {}", parent.display()) })?; } fs::write(&dest, &body).with_context(|| { format!("write {}", dest.display()) })?; } } else { // No history bundled — start a fresh git repo. let status = crate::helpers::git_command(&target, &["init"]).status()?; if !status.success() { anyhow::bail!("git init failed"); } // .gitignore — exclude reference image if present. if target.join("reference.jpg").exists() { fs::write(target.join(".gitignore"), "reference.jpg\n")?; } let _ = crate::helpers::git_command(&target, &["add", "."]).status()?; let now_iso = crate::helpers::iso8601(relicario_core::now_unix()); let msg = format!("restore from backup {now_iso}"); let _ = crate::helpers::git_command(&target, &["commit", "-m", &msg]).status()?; } eprintln!( "Restored vault to {}. Unlock with your passphrase + reference image.", target.display() ); Ok(()) } fn cmd_import(action: ImportAction) -> Result<()> { match action { ImportAction::Lastpass { csv } => cmd_import_lastpass(csv), } } fn cmd_import_lastpass(csv_path: PathBuf) -> Result<()> { use std::fs; use relicario_core::import_lastpass::parse_lastpass_csv; let csv_bytes = fs::read(&csv_path) .with_context(|| format!("failed to read CSV {}", csv_path.display()))?; let (items, warnings) = parse_lastpass_csv(&csv_bytes)?; if items.is_empty() { // Print all warnings so the user sees why nothing imported. for w in &warnings { print_warning(w); } bail!( "imported 0 items from {} — see warnings above", csv_path.display() ); } let vault = crate::session::UnlockedVault::unlock_interactive()?; let mut manifest = vault.load_manifest()?; let total = items.len(); let mut written_paths: Vec = Vec::with_capacity(items.len() + 1); for (idx, item) in items.iter().enumerate() { vault.save_item(item)?; manifest.upsert(item); written_paths.push(format!("items/{}.enc", item.id.as_str())); let n = idx + 1; if n % 50 == 0 || n == total { eprintln!("[{n}/{total}] importing..."); } } vault.save_manifest(&manifest)?; written_paths.push("manifest.enc".into()); let path_refs: Vec<&str> = written_paths.iter().map(String::as_str).collect(); let csv_filename = csv_path .file_name() .and_then(|s| s.to_str()) .unwrap_or("lastpass.csv"); commit_paths( &vault, &format!("import: {} items from LastPass ({})", total, csv_filename), &path_refs, )?; for w in &warnings { print_warning(w); } // Counts only true skips, not partial imports. Coupled by convention to // the parser's warning message strings: skip messages end in "— skipped", // partial-import messages say "imported without TOTP" / "imported without URL". // If a future warning uses the word "skipped" in any other sense, this filter // will need to switch to an enum tag (see ImportWarning::message). eprintln!( "Imported {}, skipped {} (see warnings above)", total, warnings.iter().filter(|w| w.message.contains("skipped")).count() ); Ok(()) } fn print_warning(w: &relicario_core::import_lastpass::ImportWarning) { let prefix = match &w.title { Some(t) => format!("row {} ({}):", w.row, t), None => format!("row {}:", w.row), }; eprintln!("warning: {prefix} {}", w.message); } fn cmd_trash_empty() -> Result<()> { use relicario_core::time::now_unix; let vault = crate::session::UnlockedVault::unlock_interactive()?; let mut manifest = vault.load_manifest()?; let settings = vault.load_settings()?; let now = now_unix(); let purgeable: Vec<_> = manifest.items.values() .filter(|e| match e.trashed_at { Some(t) => settings.trash_retention.should_purge(t, now), None => false, }) .map(|e| (e.id.clone(), e.title.clone())) .collect(); if purgeable.is_empty() { eprintln!("nothing past retention window"); return Ok(()); } let mut purged_titles = Vec::new(); for (id, title) in purgeable { purge_item(&vault, &mut manifest, &id, &title)?; purged_titles.push(title); } vault.save_manifest(&manifest)?; let status = crate::helpers::git_command(vault.root(), &["add", "manifest.enc"]).status()?; if !status.success() { anyhow::bail!("git add manifest.enc failed"); } let status = crate::helpers::git_command(vault.root(), &["commit", "-m", &format!("trash empty: purged {} item(s)", purged_titles.len())]).status()?; if !status.success() { anyhow::bail!("git commit failed"); } eprintln!("Emptied trash: {} item(s)", purged_titles.len()); Ok(()) } fn cmd_attach(query: String, file: PathBuf) -> Result<()> { use std::fs; use relicario_core::{encrypt_attachment, AttachmentRef}; use relicario_core::time::now_unix; let vault = crate::session::UnlockedVault::unlock_interactive()?; let mut manifest = vault.load_manifest()?; let entry = resolve_query(&manifest, &query)?; let id = entry.id.clone(); let _ = entry; let mut item = vault.load_item(&id)?; let settings = vault.load_settings()?; let caps = settings.attachment_caps; if item.attachments.len() as u32 >= caps.per_item_max_count { anyhow::bail!("item already has {} attachments (max {})", item.attachments.len(), caps.per_item_max_count); } let bytes = fs::read(&file) .with_context(|| format!("failed to read {}", file.display()))?; // Check per-vault total attachment bytes cap (audit I3). let current_total: u64 = manifest.items.values() .flat_map(|e| &e.attachment_summaries) .map(|s| s.size) .sum(); let new_size = bytes.len() as u64; let hard_cap = caps.per_vault_hard_cap_bytes; let soft_cap = caps.per_vault_soft_cap_bytes; if current_total + new_size > hard_cap { anyhow::bail!( "attachment would exceed vault hard cap ({} + {} > {} bytes)", current_total, new_size, hard_cap ); } if current_total + new_size > soft_cap { eprintln!( "warning: vault attachments will exceed soft cap ({} bytes)", soft_cap ); } 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 aref = AttachmentRef { id: enc.id.clone(), filename, mime_type, size: bytes.len() as u64, created: now_unix(), }; 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", enc.id.as_str())), &enc.bytes)?; item.attachments.push(aref); item.modified = now_unix(); vault.save_item(&item)?; manifest.upsert(&item); vault.save_manifest(&manifest)?; let paths = [ format!("items/{}.enc", item.id.as_str()), "manifest.enc".into(), format!("attachments/{}/{}.enc", item.id.as_str(), enc.id.as_str()), ]; let path_refs: Vec<&str> = paths.iter().map(|s| s.as_str()).collect(); commit_paths(&vault, &format!("attach: {} → {} ({})", crate::helpers::sanitize_for_commit(&file.display().to_string()), crate::helpers::sanitize_for_commit(&item.title), item.id.as_str()), &path_refs)?; eprintln!("Attached {} to {} (aid={})", file.display(), item.title, enc.id.as_str()); Ok(()) } fn cmd_attachments(query: String) -> Result<()> { let vault = crate::session::UnlockedVault::unlock_interactive()?; let manifest = vault.load_manifest()?; let entry = resolve_query(&manifest, &query)?; let item = vault.load_item(&entry.id)?; if item.attachments.is_empty() { eprintln!("(no attachments)"); return Ok(()); } println!("{:<17} {:>12} {:<22} {}", "AID", "SIZE", "MIME", "FILENAME"); for a in &item.attachments { println!("{:<17} {:>12} {:<22} {}", a.id.as_str(), a.size, a.mime_type, a.filename); } Ok(()) } fn cmd_extract(query: String, aid: String, out: Option) -> Result<()> { use std::fs; use relicario_core::decrypt_attachment; let vault = crate::session::UnlockedVault::unlock_interactive()?; let manifest = vault.load_manifest()?; let entry = resolve_query(&manifest, &query)?; let item = vault.load_item(&entry.id)?; let aref = item.attachments.iter().find(|a| a.id.as_str() == aid) .ok_or_else(|| anyhow::anyhow!("no attachment {aid} on {}", item.title))?; let path = vault.root().join("attachments").join(item.id.as_str()) .join(format!("{}.enc", aid)); let bytes = fs::read(&path) .with_context(|| format!("failed to read {}", path.display()))?; let plaintext = decrypt_attachment(&bytes, vault.key())?; let out_path = out.unwrap_or_else(|| PathBuf::from(&aref.filename)); fs::write(&out_path, plaintext.as_slice()) .with_context(|| format!("failed to write {}", out_path.display()))?; eprintln!("Wrote {} bytes to {}", plaintext.len(), out_path.display()); Ok(()) } fn cmd_detach(query: String, aid: String) -> Result<()> { use std::fs; use relicario_core::ItemCore; use relicario_core::time::now_unix; let vault = crate::session::UnlockedVault::unlock_interactive()?; let mut manifest = vault.load_manifest()?; let entry = resolve_query(&manifest, &query)?; let id = entry.id.clone(); let _ = entry; let mut item = vault.load_item(&id)?; let pos = item.attachments.iter().position(|a| a.id.as_str() == aid) .ok_or_else(|| anyhow::anyhow!("no attachment {aid} on {}", item.title))?; // Document items keep their primary blob in the core; refuse to orphan it. if let ItemCore::Document(d) = &item.core { if d.primary_attachment.as_str() == aid { anyhow::bail!( "cannot detach the primary attachment of a Document item; \ use `purge {}` to delete the whole item", item.title, ); } } let removed = item.attachments.remove(pos); let blob_path = vault.root().join("attachments").join(item.id.as_str()) .join(format!("{}.enc", removed.id.as_str())); if blob_path.exists() { fs::remove_file(&blob_path) .with_context(|| format!("failed to delete {}", blob_path.display()))?; } item.modified = now_unix(); vault.save_item(&item)?; manifest.upsert(&item); vault.save_manifest(&manifest)?; let item_path = format!("items/{}.enc", item.id.as_str()); let blob_relpath = format!("attachments/{}/{}.enc", item.id.as_str(), removed.id.as_str()); commit_paths( &vault, &format!("detach: {} from {} ({})", crate::helpers::sanitize_for_commit(&removed.filename), crate::helpers::sanitize_for_commit(&item.title), item.id.as_str()), &[&item_path, "manifest.enc", &blob_relpath], )?; eprintln!("Detached {} (aid={}) from {}", removed.filename, aid, item.title); Ok(()) } fn cmd_generate( length: Option, bip39: bool, words: Option, symbols: Option, separator: Option, ) -> Result<()> { use relicario_core::{ generate_passphrase, generate_password, Capitalization, CharClasses, GeneratorRequest, SymbolCharset, }; // If we're inside a vault, unlock and pull `generator_defaults`. Outside // a vault, this stays a fast standalone CSPRNG tool (no unlock prompt). let vault_defaults: Option = if crate::helpers::vault_dir().is_ok() { let vault = crate::session::UnlockedVault::unlock_interactive()?; Some(vault.load_settings()?.generator_defaults) } else { None }; // `--bip39` flag forces Bip39 mode; otherwise use whatever mode the // vault default is in (Random when no vault). let use_bip39 = bip39 || matches!(vault_defaults, Some(GeneratorRequest::Bip39 { .. })); let output = if use_bip39 { let (def_words, def_sep, def_cap) = match &vault_defaults { Some(GeneratorRequest::Bip39 { word_count, separator, capitalization }) => { (*word_count, separator.clone(), *capitalization) } _ => (5, " ".to_string(), Capitalization::Lower), }; generate_passphrase(&GeneratorRequest::Bip39 { word_count: words.unwrap_or(def_words), separator: separator.unwrap_or(def_sep), capitalization: def_cap, })? } else { let (def_length, def_classes, def_charset) = match &vault_defaults { Some(GeneratorRequest::Random { length, classes, symbol_charset }) => { (*length, *classes, symbol_charset.clone()) } _ => ( 20, CharClasses { lower: true, upper: true, digits: true, symbols: true }, SymbolCharset::SafeOnly, ), }; let symbol_charset = match symbols.as_deref() { None => def_charset, Some("safe") => SymbolCharset::SafeOnly, Some("extended") => SymbolCharset::Extended, Some(other) => SymbolCharset::Custom(other.to_string()), }; generate_password(&GeneratorRequest::Random { length: length.unwrap_or(def_length), classes: def_classes, symbol_charset, })? }; println!("{}", output.as_str()); Ok(()) } fn cmd_settings(action: SettingsAction) -> Result<()> { use relicario_core::{ Capitalization, CharClasses, GeneratorRequest, HistoryRetention, SymbolCharset, TrashRetention, }; let vault = crate::session::UnlockedVault::unlock_interactive()?; let mut settings = vault.load_settings()?; match action { SettingsAction::Show => { println!("{}", serde_json::to_string_pretty(&settings)?); return Ok(()); } SettingsAction::TrashRetention { days, forever } => { settings.trash_retention = match (days, forever) { (Some(d), false) => TrashRetention::Days(d), (None, true) => TrashRetention::Forever, _ => anyhow::bail!("specify exactly one of --days or --forever"), }; } SettingsAction::HistoryRetention { last_n, days, forever } => { settings.field_history_retention = match (last_n, days, forever) { (Some(n), None, false) => HistoryRetention::LastN(n), (None, Some(d), false) => HistoryRetention::Days(d), (None, None, true) => HistoryRetention::Forever, _ => anyhow::bail!("specify exactly one of --last-n / --days / --forever"), }; } SettingsAction::AttachmentCap { per_attachment_max_bytes, per_item_max_count, per_vault_soft_cap_bytes, per_vault_hard_cap_bytes, } => { if let Some(v) = per_attachment_max_bytes { settings.attachment_caps.per_attachment_max_bytes = v; } if let Some(v) = per_item_max_count { settings.attachment_caps.per_item_max_count = v; } if let Some(v) = per_vault_soft_cap_bytes { settings.attachment_caps.per_vault_soft_cap_bytes = v; } if let Some(v) = per_vault_hard_cap_bytes { settings.attachment_caps.per_vault_hard_cap_bytes = v; } } SettingsAction::GeneratorDefaults { random, bip39, length, words, symbols, separator, } => { // Decide target mode: explicit flag wins, else preserve current. let target_bip39 = if random { false } else if bip39 { true } else { matches!(settings.generator_defaults, GeneratorRequest::Bip39 { .. }) }; // Pull existing fields where compatible, else seed with sensible // defaults (kept in sync with `GeneratorRequest::default()`). let (cur_length, cur_classes, cur_charset) = match &settings.generator_defaults { GeneratorRequest::Random { length, classes, symbol_charset } => { (*length, *classes, symbol_charset.clone()) } _ => ( 20, CharClasses { lower: true, upper: true, digits: true, symbols: true }, SymbolCharset::SafeOnly, ), }; let (cur_words, cur_sep, cur_cap) = match &settings.generator_defaults { GeneratorRequest::Bip39 { word_count, separator, capitalization } => { (*word_count, separator.clone(), *capitalization) } _ => (5, " ".to_string(), Capitalization::Lower), }; settings.generator_defaults = if target_bip39 { GeneratorRequest::Bip39 { word_count: words.unwrap_or(cur_words), separator: separator.unwrap_or(cur_sep), capitalization: cur_cap, } } else { let charset = match symbols.as_deref() { None => cur_charset, Some("safe") => SymbolCharset::SafeOnly, Some("extended") => SymbolCharset::Extended, Some(other) => SymbolCharset::Custom(other.to_string()), }; GeneratorRequest::Random { length: length.unwrap_or(cur_length), classes: cur_classes, symbol_charset: charset, } }; } } vault.save_settings(&settings)?; commit_paths(&vault, "settings: update", &["settings.enc"])?; eprintln!("Settings updated."); Ok(()) } fn cmd_sync() -> Result<()> { let root = crate::helpers::vault_dir()?; let pull = crate::helpers::git_command(&root, &["pull", "--rebase"]).status()?; if !pull.success() { anyhow::bail!("git pull --rebase failed"); } let push = crate::helpers::git_command(&root, &["push"]).status()?; if !push.success() { anyhow::bail!("git push failed"); } eprintln!("Sync complete."); Ok(()) } fn cmd_status() -> Result<()> { let vault = crate::session::UnlockedVault::unlock_interactive()?; let root = vault.root().to_path_buf(); let manifest = vault.load_manifest()?; let total_items = manifest.items.len(); let trashed_items = manifest.items.values().filter(|e| e.trashed_at.is_some()).count(); let active_items = total_items - trashed_items; let (attachment_count, attachment_bytes) = manifest.items.values() .flat_map(|e| e.attachment_summaries.iter()) .fold((0u64, 0u64), |(c, b), s| (c + 1, b + s.size)); let last_commit = crate::helpers::git_command(&root, &[ "log", "-1", "--pretty=format:%h %s", ]).output() .ok() .and_then(|o| String::from_utf8(o.stdout).ok()) .map(|s| s.trim().to_string()) .unwrap_or_else(|| "(no commits)".into()); // Last backup age (read from marker written by cmd_backup_export). let last_backup_path = vault.root().join(".relicario").join("last_backup"); let last_backup_str = if last_backup_path.exists() { let line = std::fs::read_to_string(&last_backup_path) .unwrap_or_default() .trim() .to_string(); // Parse the ISO-8601 we wrote in cmd_backup_export. match chrono::DateTime::parse_from_rfc3339(&line) { Ok(then) => { let now = relicario_core::now_unix(); let age = now - then.timestamp(); crate::helpers::humanize_age(age.max(0)) } Err(_) => "unknown".to_string(), } } else { "never".to_string() }; println!("Vault: {}", root.display()); println!("Items: {total_items} total ({active_items} active, {trashed_items} trashed)"); println!("Attachments: {attachment_count} ({attachment_bytes} bytes)"); println!("Last commit: {last_commit}"); println!("Last export: {last_backup_str}"); Ok(()) } #[derive(serde::Serialize)] struct ParamsFile { format_version: u32, kdf: ParamsKdf, aead: String, salt_path: String, } #[derive(serde::Serialize)] #[serde(rename_all = "snake_case")] struct ParamsKdf { algorithm: String, argon2_m: u32, argon2_t: u32, argon2_p: u32, } fn cmd_rate(passphrase: String) -> Result<()> { let pw: String = if passphrase == "-" { use std::io::BufRead; let stdin = std::io::stdin(); let mut line = String::new(); stdin.lock().read_line(&mut line)?; line.trim_end_matches(&['\r', '\n'][..]).to_string() } else { passphrase }; let est = relicario_core::generators::rate_passphrase(&pw); let label = match est.score { 0 => "very weak", 1 => "weak", 2 => "fair", 3 => "good", 4 => "strong", _ => "?", }; println!("score: {}/4 ({})", est.score, label); println!("guesses: ~10^{:.1}", est.guesses_log10); println!("note: init requires score ≥ 3 (see `relicario init`)"); Ok(()) } // ── Device management ───────────────────────────────────────────────────────── /// Build a `GiteaClient` from flags or environment variables. fn load_gitea_client( gitea_url: Option, gitea_token: Option, owner: Option, repo: Option, ) -> Result { let url = gitea_url .or_else(|| std::env::var("RELICARIO_GITEA_URL").ok()) .ok_or_else(|| anyhow::anyhow!( "Gitea URL required — pass --gitea-url or set RELICARIO_GITEA_URL" ))?; let token = gitea_token .or_else(|| std::env::var("RELICARIO_GITEA_TOKEN").ok()) .ok_or_else(|| anyhow::anyhow!( "Gitea token required — pass --gitea-token or set RELICARIO_GITEA_TOKEN" ))?; let owner = owner .or_else(|| std::env::var("RELICARIO_GITEA_OWNER").ok()) .ok_or_else(|| anyhow::anyhow!( "Gitea owner required — pass --owner or set RELICARIO_GITEA_OWNER" ))?; let repo = repo .or_else(|| std::env::var("RELICARIO_GITEA_REPO").ok()) .ok_or_else(|| anyhow::anyhow!( "Gitea repo required — pass --repo or set RELICARIO_GITEA_REPO" ))?; Ok(crate::gitea::GiteaClient::new(&url, &token, &owner, &repo)) } fn cmd_device(action: DeviceAction) -> Result<()> { use std::fs; use relicario_core::device::{DeviceEntry, RevokedEntry, generate_keypair}; let root = crate::helpers::vault_dir()?; let relicario_dir = root.join(".relicario"); let devices_path = relicario_dir.join("devices.json"); match action { DeviceAction::Add { name, gitea_url, gitea_token, owner, repo, no_gitea } => { // Guard: don't overwrite an already-registered device name. let existing: Vec = fs::read(&devices_path) .ok() .and_then(|b| serde_json::from_slice(&b).ok()) .unwrap_or_default(); if existing.iter().any(|d| d.name == name) { anyhow::bail!("a device named '{}' is already registered", name); } eprintln!("Generating signing keypair..."); let (signing_priv, signing_pub) = generate_keypair() .map_err(|e| anyhow::anyhow!("generate signing keypair: {e}"))?; eprintln!("Generating deploy keypair..."); let (deploy_priv, deploy_pub) = generate_keypair() .map_err(|e| anyhow::anyhow!("generate deploy keypair: {e}"))?; // Optionally register deploy key with Gitea. let gitea_key_id: u64 = if no_gitea { eprintln!("Skipping Gitea deploy key registration (--no-gitea)."); 0 } else { let client = load_gitea_client(gitea_url, gitea_token, owner, repo)?; let key_title = format!("relicario-{}", name); eprintln!("Registering deploy key '{}' with Gitea...", key_title); client.create_deploy_key(&key_title, &deploy_pub)? }; // Store keys locally with proper permissions. crate::device::store_device_keys( &name, &signing_priv, &signing_pub, &deploy_priv, &deploy_pub, gitea_key_id, )?; // Mark as current device. crate::device::set_current_device(&name)?; // Configure git signing + SSH deploy key in the vault repo. crate::device::configure_git_signing(&root, &name)?; // Update devices.json. let current_name = name.clone(); let mut devices = existing; devices.push(DeviceEntry { name: name.clone(), public_key: signing_pub.clone(), added_at: relicario_core::now_unix(), added_by: current_name, }); fs::create_dir_all(&relicario_dir)?; fs::write(&devices_path, serde_json::to_string_pretty(&devices)?)?; // Commit the update. let status = crate::helpers::git_command( &root, &["add", ".relicario/devices.json"], ) .status()?; if !status.success() { anyhow::bail!("git add .relicario/devices.json failed"); } let msg = format!("device: register {}", name); let status = crate::helpers::git_command(&root, &["commit", "-m", &msg]) .status()?; if !status.success() { anyhow::bail!("git commit failed"); } eprintln!("Device '{}' registered.", name); eprintln!("Signing public key:"); eprintln!(" {}", signing_pub); if gitea_key_id != 0 { eprintln!("Gitea deploy key ID: {}", gitea_key_id); } Ok(()) } DeviceAction::Revoke { name } => { // Guard: refuse to revoke the currently active device (would lock // the user out). They must add another device first. if let Some(current) = crate::device::current_device()? { if current == name { anyhow::bail!( "cannot revoke the current device '{}' — you would lose \ push access. Register another device first.", name ); } } // Load devices.json. let mut devices: Vec = fs::read(&devices_path) .ok() .and_then(|b| serde_json::from_slice(&b).ok()) .unwrap_or_default(); let device = devices .iter() .find(|d| d.name == name) .ok_or_else(|| anyhow::anyhow!("device '{}' not found", name))? .clone(); // Remove from devices.json. devices.retain(|d| d.name != name); fs::write(&devices_path, serde_json::to_string_pretty(&devices)?)?; // Append to revoked.json. let revoked_path = relicario_dir.join("revoked.json"); let mut revoked: Vec = fs::read(&revoked_path) .ok() .and_then(|b| serde_json::from_slice(&b).ok()) .unwrap_or_default(); let revoked_by = crate::device::current_device()? .unwrap_or_else(|| "unknown".to_string()); revoked.push(RevokedEntry { name: name.clone(), public_key: device.public_key.clone(), revoked_at: relicario_core::now_unix(), revoked_by, }); fs::write(&revoked_path, serde_json::to_string_pretty(&revoked)?)?; // Delete deploy key from Gitea (best-effort — don't fail if it // was already deleted or the config is missing). if let Ok(key_id) = crate::device::load_gitea_key_id(&name) { if key_id != 0 { // Build client from env vars only (no flags in revoke). match load_gitea_client(None, None, None, None) { Ok(client) => { if let Err(e) = client.delete_deploy_key(key_id) { eprintln!( "warning: failed to delete Gitea deploy key {}: {}", key_id, e ); } else { eprintln!("Deleted Gitea deploy key {}.", key_id); } } Err(_) => { eprintln!( "warning: Gitea env vars not set — deploy key {} \ not deleted from Gitea.", key_id ); } } } } // Commit devices.json + revoked.json (always both — revoked.json // was just written above so it is guaranteed to exist). let add_args = [ "add", ".relicario/devices.json", ".relicario/revoked.json", ]; let status = crate::helpers::git_command(&root, &add_args).status()?; if !status.success() { anyhow::bail!("git add failed"); } let msg = format!("device: revoke {}", name); let status = crate::helpers::git_command(&root, &["commit", "-m", &msg]) .status()?; if !status.success() { anyhow::bail!("git commit failed"); } eprintln!("Device '{}' revoked.", name); eprintln!("Revoked signing key: {}", device.public_key); Ok(()) } DeviceAction::List => { let devices: Vec = fs::read(&devices_path) .ok() .and_then(|b| serde_json::from_slice(&b).ok()) .unwrap_or_default(); let current = crate::device::current_device()?.unwrap_or_default(); if devices.is_empty() { println!("No registered devices."); return Ok(()); } println!("{:<20} {:<20} {}", "NAME", "ADDED", "SIGNING KEY (prefix)"); println!("{}", "-".repeat(72)); for d in &devices { let marker = if d.name == current { " *" } else { "" }; let added = crate::helpers::iso8601(d.added_at); // Show only the first 40 chars of the public key line for readability. let key_prefix: String = d.public_key.chars().take(40).collect(); println!("{:<20} {:<20} {}{}", d.name, added, key_prefix, marker); } if !current.is_empty() { println!("\n* = current device"); } Ok(()) } } }