cmd_backup_restore previously called tar::Archive::unpack with default settings, allowing malicious .relbak archives to escape the target directory via .. entries, absolute paths, or symlinks. No size cap meant tar bombs could exhaust disk space. Replaced with relicario_core::safe_unpack_git_archive which: - Rejects .. (ParentDir), absolute (RootDir), and drive-prefix (Prefix) components with "path traversal blocked" error. - Rejects symlinks and hardlinks outright. - Checks declared header size before reading body; rejects entries or cumulative totals exceeding the caller's cap. - Returns (relative-path, bytes) pairs; the CLI re-checks dest.starts_with(git_dir) after OS-level path resolution. - CLI cap: min(100 × compressed size, 1 GiB). Acceptance: 5 unit tests in relicario-core (traversal, absolute path, symlink, size bomb, happy path); existing CLI backup roundtrip tests remain green. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2561 lines
95 KiB
Rust
2561 lines
95 KiB
Rust
//! 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<String>,
|
||
#[arg(long)]
|
||
group: Option<String>,
|
||
#[arg(long)]
|
||
tag: Option<String>,
|
||
#[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<PathBuf>,
|
||
},
|
||
|
||
/// 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<String>,
|
||
},
|
||
|
||
/// 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<PathBuf>,
|
||
},
|
||
|
||
/// 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<u32>,
|
||
#[arg(long)]
|
||
bip39: bool,
|
||
#[arg(long)]
|
||
words: Option<u32>,
|
||
#[arg(long)]
|
||
symbols: Option<String>,
|
||
/// Separator for BIP39 words.
|
||
#[arg(long)]
|
||
separator: Option<String>,
|
||
},
|
||
|
||
/// 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 <TAB>` 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<String>,
|
||
#[arg(long)] username: Option<String>,
|
||
#[arg(long)] url: Option<String>,
|
||
/// Prompt for password (vs reading from stdin or --password).
|
||
#[arg(long)] password_prompt: bool,
|
||
#[arg(long)] password: Option<String>,
|
||
#[arg(long)] group: Option<String>,
|
||
#[arg(long, value_delimiter = ',')] tags: Vec<String>,
|
||
#[arg(long)] favorite: bool,
|
||
/// Decode an `otpauth://` QR image to fill the TOTP secret.
|
||
#[arg(long, value_name = "PATH")] totp_qr: Option<PathBuf>,
|
||
},
|
||
SecureNote {
|
||
#[arg(long)] title: Option<String>,
|
||
#[arg(long)] body_prompt: bool,
|
||
#[arg(long)] group: Option<String>,
|
||
#[arg(long, value_delimiter = ',')] tags: Vec<String>,
|
||
},
|
||
Identity {
|
||
#[arg(long)] title: Option<String>,
|
||
#[arg(long)] full_name: Option<String>,
|
||
#[arg(long)] email: Option<String>,
|
||
#[arg(long)] phone: Option<String>,
|
||
#[arg(long)] date_of_birth: Option<String>,
|
||
#[arg(long)] group: Option<String>,
|
||
#[arg(long, value_delimiter = ',')] tags: Vec<String>,
|
||
},
|
||
Card {
|
||
#[arg(long)] title: Option<String>,
|
||
#[arg(long)] holder: Option<String>,
|
||
#[arg(long)] expiry: Option<String>, // MM/YYYY
|
||
#[arg(long, default_value = "credit")] kind: String,
|
||
#[arg(long)] group: Option<String>,
|
||
#[arg(long, value_delimiter = ',')] tags: Vec<String>,
|
||
},
|
||
Key {
|
||
#[arg(long)] title: Option<String>,
|
||
#[arg(long)] label: Option<String>,
|
||
#[arg(long)] algorithm: Option<String>,
|
||
#[arg(long)] group: Option<String>,
|
||
#[arg(long, value_delimiter = ',')] tags: Vec<String>,
|
||
},
|
||
Document {
|
||
#[arg(long)] title: Option<String>,
|
||
#[arg(long)] file: PathBuf,
|
||
#[arg(long)] group: Option<String>,
|
||
#[arg(long, value_delimiter = ',')] tags: Vec<String>,
|
||
},
|
||
Totp {
|
||
#[arg(long)] title: Option<String>,
|
||
#[arg(long)] issuer: Option<String>,
|
||
#[arg(long)] label: Option<String>,
|
||
#[arg(long)] secret: Option<String>, // 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<String>,
|
||
#[arg(long, value_delimiter = ',')] tags: Vec<String>,
|
||
},
|
||
}
|
||
|
||
#[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<u32>,
|
||
#[arg(long)] forever: bool,
|
||
},
|
||
/// Set field history retention.
|
||
HistoryRetention {
|
||
#[arg(long)] last_n: Option<u32>,
|
||
#[arg(long)] days: Option<u32>,
|
||
#[arg(long)] forever: bool,
|
||
},
|
||
/// Set per-attachment max size in bytes.
|
||
AttachmentCap {
|
||
#[arg(long)] per_attachment_max_bytes: Option<u64>,
|
||
#[arg(long)] per_item_max_count: Option<u32>,
|
||
#[arg(long)] per_vault_soft_cap_bytes: Option<u64>,
|
||
#[arg(long)] per_vault_hard_cap_bytes: Option<u64>,
|
||
},
|
||
/// 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<u32>,
|
||
/// BIP39 mode: number of words.
|
||
#[arg(long)] words: Option<u32>,
|
||
/// Random mode: symbol charset (`safe`, `extended`, or a custom literal).
|
||
#[arg(long)] symbols: Option<String>,
|
||
/// BIP39 mode: word separator.
|
||
#[arg(long)] separator: Option<String>,
|
||
},
|
||
}
|
||
|
||
#[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<PathBuf>,
|
||
/// 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/<name>/`. 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<String>,
|
||
/// Gitea personal access token (overrides RELICARIO_GITEA_TOKEN).
|
||
#[arg(long)]
|
||
gitea_token: Option<String>,
|
||
/// Gitea repository owner (overrides RELICARIO_GITEA_OWNER).
|
||
#[arg(long)]
|
||
owner: Option<String>,
|
||
/// Gitea repository name (overrides RELICARIO_GITEA_REPO).
|
||
#[arg(long)]
|
||
repo: Option<String>,
|
||
/// 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::<String>::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<String> {
|
||
std::env::var("RELICARIO_TEST_PASSPHRASE").ok()
|
||
}
|
||
#[cfg(not(debug_assertions))]
|
||
pub(crate) fn test_passphrase_override() -> Option<String> {
|
||
None
|
||
}
|
||
|
||
/// Check for test item secret override (debug builds only; stripped from release).
|
||
#[cfg(debug_assertions)]
|
||
fn test_item_secret_override() -> Option<String> {
|
||
std::env::var("RELICARIO_TEST_ITEM_SECRET").ok()
|
||
}
|
||
#[cfg(not(debug_assertions))]
|
||
fn test_item_secret_override() -> Option<String> {
|
||
None
|
||
}
|
||
|
||
/// Check for test backup passphrase override (debug builds only; stripped from release).
|
||
#[cfg(debug_assertions)]
|
||
fn test_backup_passphrase_override() -> Option<String> {
|
||
std::env::var("RELICARIO_TEST_BACKUP_PASSPHRASE").ok()
|
||
}
|
||
#[cfg(not(debug_assertions))]
|
||
fn test_backup_passphrase_override() -> Option<String> {
|
||
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<String> {
|
||
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<String> = 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<String>,
|
||
username: Option<String>,
|
||
url: Option<String>,
|
||
password_prompt: bool,
|
||
password: Option<String>,
|
||
group: Option<String>,
|
||
tags: Vec<String>,
|
||
favorite: bool,
|
||
totp_qr: Option<PathBuf>,
|
||
) -> Result<relicario_core::Item> {
|
||
use relicario_core::item_types::{LoginCore, TotpAlgorithm, TotpConfig, TotpKind};
|
||
use relicario_core::{Item, ItemCore};
|
||
use zeroize::Zeroizing;
|
||
|
||
let title = 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<String>,
|
||
body_prompt: bool,
|
||
group: Option<String>,
|
||
tags: Vec<String>,
|
||
) -> Result<relicario_core::Item> {
|
||
use relicario_core::item_types::SecureNoteCore;
|
||
use relicario_core::{Item, ItemCore};
|
||
use zeroize::Zeroizing;
|
||
|
||
let title = 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<String>,
|
||
full_name: Option<String>,
|
||
email: Option<String>,
|
||
phone: Option<String>,
|
||
date_of_birth: Option<String>,
|
||
group: Option<String>,
|
||
tags: Vec<String>,
|
||
) -> Result<relicario_core::Item> {
|
||
use relicario_core::item_types::IdentityCore;
|
||
use relicario_core::{Item, ItemCore};
|
||
|
||
let title = 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<String>,
|
||
holder: Option<String>,
|
||
expiry: Option<String>,
|
||
kind: String,
|
||
group: Option<String>,
|
||
tags: Vec<String>,
|
||
) -> Result<relicario_core::Item> {
|
||
use relicario_core::item_types::{CardCore, CardKind};
|
||
use relicario_core::{Item, ItemCore};
|
||
use zeroize::Zeroizing;
|
||
|
||
let title = 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<String>,
|
||
label: Option<String>,
|
||
algorithm: Option<String>,
|
||
group: Option<String>,
|
||
tags: Vec<String>,
|
||
) -> Result<relicario_core::Item> {
|
||
use relicario_core::item_types::KeyCore;
|
||
use relicario_core::{Item, ItemCore};
|
||
use zeroize::Zeroizing;
|
||
|
||
let title = 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<String>,
|
||
file: PathBuf,
|
||
group: Option<String>,
|
||
tags: Vec<String>,
|
||
) -> Result<relicario_core::Item> {
|
||
use relicario_core::item_types::DocumentCore;
|
||
use relicario_core::{encrypt_attachment, AttachmentRef, Item, ItemCore};
|
||
use std::fs;
|
||
|
||
let title = 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<String>,
|
||
issuer: Option<String>,
|
||
label: Option<String>,
|
||
secret: Option<String>,
|
||
period: u32,
|
||
digits: u8,
|
||
algorithm: String,
|
||
group: Option<String>,
|
||
tags: Vec<String>,
|
||
) -> Result<relicario_core::Item> {
|
||
use relicario_core::item_types::{TotpAlgorithm, TotpConfig, TotpCore, TotpKind};
|
||
use relicario_core::{Item, ItemCore};
|
||
use zeroize::Zeroizing;
|
||
|
||
let title = 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<String> {
|
||
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<Option<String>> {
|
||
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<relicario_core::MonthYear> {
|
||
// 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::<u16>().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<Vec<u8>> {
|
||
let cleaned: String = s.chars()
|
||
.filter(|c| !c.is_whitespace())
|
||
.collect::<String>()
|
||
.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<Zeroizing<String>> = 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<String>) -> 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<String>,
|
||
group_filter: Option<String>,
|
||
tag_filter: Option<String>,
|
||
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<ItemType> = 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<PathBuf>) -> 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<relicario_core::item::FieldHistoryEntry>,
|
||
>;
|
||
|
||
fn edit_login(
|
||
l: &mut relicario_core::item_types::LoginCore,
|
||
history: &mut FieldHistory,
|
||
totp_qr: Option<PathBuf>,
|
||
) -> Result<()> {
|
||
use relicario_core::item_types::{TotpAlgorithm, TotpConfig, TotpKind};
|
||
use zeroize::Zeroizing;
|
||
if let Some(v) = prompt_keep_opt("Username", l.username.as_deref())? { l.username = Some(v); }
|
||
if let Some(v) = prompt_keep_opt("URL", l.url.as_ref().map(|u| u.as_str()))? {
|
||
l.url = Some(url::Url::parse(&v).with_context(|| format!("invalid URL: {v}"))?);
|
||
}
|
||
if prompt_yesno("Change password?")? {
|
||
let old = l.password.clone();
|
||
l.password = Some(Zeroizing::new(prompt_secret("New password: ")?));
|
||
if let Some(old_pw) = old {
|
||
push_history(history, "login_password", Zeroizing::new(old_pw.as_str().to_string()));
|
||
}
|
||
}
|
||
if let Some(path) = totp_qr {
|
||
let secret_b32 = crate::helpers::decode_totp_qr(&path)?;
|
||
let secret_bytes = base32_decode_lenient(&secret_b32)?;
|
||
l.totp = Some(TotpConfig {
|
||
secret: Zeroizing::new(secret_bytes),
|
||
algorithm: TotpAlgorithm::Sha1,
|
||
digits: 6,
|
||
period_seconds: 30,
|
||
kind: TotpKind::Totp,
|
||
});
|
||
eprintln!("TOTP secret set from QR image.");
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn edit_secure_note(n: &mut relicario_core::item_types::SecureNoteCore, history: &mut FieldHistory) -> Result<()> {
|
||
use zeroize::Zeroizing;
|
||
if prompt_yesno("Edit body?")? {
|
||
let old = n.body.clone();
|
||
eprintln!("Enter new body; end with Ctrl-D:");
|
||
let mut s = String::new();
|
||
std::io::Read::read_to_string(&mut std::io::stdin(), &mut s)?;
|
||
n.body = Zeroizing::new(s);
|
||
push_history(history, "secure_note_body", Zeroizing::new(old.as_str().to_string()));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn edit_identity(i: &mut relicario_core::item_types::IdentityCore) -> Result<()> {
|
||
if let Some(v) = prompt_keep_opt("Full name", i.full_name.as_deref())? { i.full_name = Some(v); }
|
||
if let Some(v) = prompt_keep_opt("Email", i.email.as_deref())? { i.email = Some(v); }
|
||
if let Some(v) = prompt_keep_opt("Phone", i.phone.as_deref())? { i.phone = Some(v); }
|
||
Ok(())
|
||
}
|
||
|
||
fn edit_card(c: &mut relicario_core::item_types::CardCore, history: &mut FieldHistory) -> Result<()> {
|
||
use zeroize::Zeroizing;
|
||
if let Some(v) = prompt_keep_opt("Holder", c.holder.as_deref())? { c.holder = Some(v); }
|
||
if prompt_yesno("Change card number?")? {
|
||
let old = c.number.clone();
|
||
c.number = Some(Zeroizing::new(prompt_secret("New number: ")?));
|
||
if let Some(o) = old {
|
||
push_history(history, "card_number", Zeroizing::new(o.as_str().to_string()));
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn edit_key(k: &mut relicario_core::item_types::KeyCore, history: &mut FieldHistory) -> Result<()> {
|
||
use zeroize::Zeroizing;
|
||
if prompt_yesno("Replace key material?")? {
|
||
eprintln!("Paste new key material; end with Ctrl-D:");
|
||
let mut s = String::new();
|
||
std::io::Read::read_to_string(&mut std::io::stdin(), &mut s)?;
|
||
let old = k.key_material.clone();
|
||
k.key_material = Zeroizing::new(s);
|
||
push_history(history, "key_material", Zeroizing::new(old.as_str().to_string()));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn edit_document_message() {
|
||
eprintln!("Document items: use `relicario attach` / `relicario extract` instead.");
|
||
}
|
||
|
||
fn edit_totp(t: &mut relicario_core::item_types::TotpCore, history: &mut FieldHistory) -> Result<()> {
|
||
use zeroize::Zeroizing;
|
||
if let Some(v) = prompt_keep_opt("Issuer", t.issuer.as_deref())? { t.issuer = Some(v); }
|
||
if let Some(v) = prompt_keep_opt("Label", t.label.as_deref())? { t.label = Some(v); }
|
||
if prompt_yesno("Change TOTP secret?")? {
|
||
let old_b32 = data_encoding::BASE32.encode(&t.config.secret);
|
||
let new_b32 = prompt_secret("New TOTP secret (base32): ")?;
|
||
let new_bytes = base32_decode_lenient(&new_b32)?;
|
||
t.config.secret = Zeroizing::new(new_bytes);
|
||
push_history(history, "totp_secret", Zeroizing::new(old_b32));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn prompt_keep(label: &str, current: &str) -> Result<Option<String>> {
|
||
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<Option<String>> {
|
||
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<bool> {
|
||
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<relicario_core::FieldId, Vec<relicario_core::item::FieldHistoryEntry>>,
|
||
synthetic_key: &str,
|
||
old_value: zeroize::Zeroizing<String>,
|
||
) {
|
||
use relicario_core::item::FieldHistoryEntry;
|
||
use relicario_core::time::now_unix;
|
||
// Synthetic FieldId for core-level fields — stable per-item (prefixed so
|
||
// custom-field UUIDs can't collide).
|
||
let fid = relicario_core::FieldId(format!("core:{synthetic_key}"));
|
||
history.entry(fid).or_default().push(FieldHistoryEntry {
|
||
value: old_value,
|
||
replaced_at: now_unix(),
|
||
});
|
||
}
|
||
|
||
fn cmd_history(query: String, show: bool, field: Option<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)?;
|
||
|
||
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<PathBuf>,
|
||
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/<item_id>/<aid>.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<backup::BackupItem> = item_files.iter()
|
||
.map(|(id, bytes)| backup::BackupItem { id: id.clone(), ciphertext: bytes })
|
||
.collect();
|
||
let attach_refs: Vec<backup::BackupAttachment> = 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<u8>`. Used for `.git/` bundling.
|
||
fn tar_directory(dir: &std::path::Path) -> Result<Vec<u8>> {
|
||
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<String> = 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<PathBuf>) -> 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<u32>,
|
||
bip39: bool,
|
||
words: Option<u32>,
|
||
symbols: Option<String>,
|
||
separator: Option<String>,
|
||
) -> 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<GeneratorRequest> = 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<String>,
|
||
gitea_token: Option<String>,
|
||
owner: Option<String>,
|
||
repo: Option<String>,
|
||
) -> Result<crate::gitea::GiteaClient> {
|
||
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<DeviceEntry> = 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<DeviceEntry> = 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<RevokedEntry> = 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<DeviceEntry> = 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(())
|
||
}
|
||
}
|
||
}
|