diff --git a/crates/relicario-cli/src/commands/get.rs b/crates/relicario-cli/src/commands/get.rs new file mode 100644 index 0000000..4618a3a --- /dev/null +++ b/crates/relicario-cli/src/commands/get.rs @@ -0,0 +1,107 @@ +//! `relicario get` — print a single item, masking secrets unless `--show`. + +use anyhow::{Context, Result}; + +pub 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()?; + crate::refresh_groups_cache(vault.root(), &manifest); + let entry = super::resolve_query(&manifest, &query)?; + let item = vault.load_item(&entry.id)?; + + println!("ID: {}", item.id.as_str()); + println!("Title: {}", item.title); + println!("Type: {:?}", item.r#type); + if let Some(g) = &item.group { println!("Group: {g}"); } + if !item.tags.is_empty() { println!("Tags: {}", item.tags.join(", ")); } + println!("Created: {}", crate::helpers::iso8601(item.created)); + println!("Modified: {}", crate::helpers::iso8601(item.modified)); + if let Some(t) = item.trashed_at { println!("Trashed: {}", crate::helpers::iso8601(t)); } + println!(); + + let primary_secret: Option> = match &item.core { + ItemCore::Login(l) => { + if let Some(u) = &l.username { println!("Username: {u}"); } + if let Some(u) = &l.url { println!("URL: {u}"); } + if let Some(t) = &l.totp { + if show { + println!("TOTP: {}", data_encoding::BASE32.encode(&t.secret)); + } else { + println!("TOTP: **** (use --show to reveal)"); + } + } + l.password.clone() + } + 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 copy_to_clipboard_then_clear(secret: &zeroize::Zeroizing) -> Result<()> { + use arboard::Clipboard; + let mut cb = Clipboard::new().context("failed to access clipboard")?; + cb.set_text(secret.as_str().to_string()).context("failed to write clipboard")?; + let cleared_copy = zeroize::Zeroizing::new(secret.as_str().to_owned()); + // Unconditional clear (audit M6): spawn a detached thread that waits 30s + // and then rewrites the clipboard with empty string. Even if the user + // copies something else in the interim, we still overwrite once. + std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_secs(30)); + if let Ok(mut cb) = Clipboard::new() { + let _ = cb.set_text(String::new()); + drop(cleared_copy); // zeroize the detached copy + } + }); + Ok(()) +} diff --git a/crates/relicario-cli/src/commands/list.rs b/crates/relicario-cli/src/commands/list.rs new file mode 100644 index 0000000..1c153a5 --- /dev/null +++ b/crates/relicario-cli/src/commands/list.rs @@ -0,0 +1,103 @@ +//! `relicario list` and `relicario history` — both read-only browse paths. + +use anyhow::Result; + +pub fn cmd_list( + type_filter: Option, + group_filter: Option, + tag_filter: Option, + trashed: bool, +) -> Result<()> { + use relicario_core::ItemType; + + let vault = crate::session::UnlockedVault::unlock_interactive()?; + let manifest = vault.load_manifest()?; + crate::refresh_groups_cache(vault.root(), &manifest); + + let parsed_type: Option = match type_filter.as_deref() { + None => None, + Some("login") => Some(ItemType::Login), + Some("secure_note") | Some("note") => Some(ItemType::SecureNote), + Some("identity") => Some(ItemType::Identity), + Some("card") => Some(ItemType::Card), + Some("key") => Some(ItemType::Key), + Some("document") => Some(ItemType::Document), + Some("totp") => Some(ItemType::Totp), + Some(other) => anyhow::bail!("unknown type filter: {other}"), + }; + + let mut entries: Vec<_> = manifest.items.values() + .filter(|e| { + if trashed { e.trashed_at.is_some() } else { e.trashed_at.is_none() } + }) + .filter(|e| match parsed_type { + Some(t) => e.r#type == t, + None => true, + }) + .filter(|e| group_filter.as_ref().is_none_or(|g| e.group.as_deref() == Some(g.as_str()))) + .filter(|e| tag_filter.as_ref().is_none_or(|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} TITLE", "ID", "TYPE", "FAV"); + 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(()) +} + +pub fn cmd_history(query: String, show: bool, field: Option) -> Result<()> { + let vault = crate::session::UnlockedVault::unlock_interactive()?; + let manifest = vault.load_manifest()?; + let entry = super::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(()) +} diff --git a/crates/relicario-cli/src/commands/mod.rs b/crates/relicario-cli/src/commands/mod.rs index f5e1a1f..90ac424 100644 --- a/crates/relicario-cli/src/commands/mod.rs +++ b/crates/relicario-cli/src/commands/mod.rs @@ -7,8 +7,12 @@ //! `use crate::commands::*`. pub mod generate; +pub mod get; pub mod init; +pub mod list; pub mod rate; +pub mod status; +pub mod sync; use anyhow::Result; diff --git a/crates/relicario-cli/src/commands/status.rs b/crates/relicario-cli/src/commands/status.rs new file mode 100644 index 0000000..788b062 --- /dev/null +++ b/crates/relicario-cli/src/commands/status.rs @@ -0,0 +1,52 @@ +//! `relicario status` — vault-level summary (counts, last commit, last backup). + +use anyhow::Result; + +pub 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(()) +} diff --git a/crates/relicario-cli/src/commands/sync.rs b/crates/relicario-cli/src/commands/sync.rs new file mode 100644 index 0000000..a1f797a --- /dev/null +++ b/crates/relicario-cli/src/commands/sync.rs @@ -0,0 +1,13 @@ +//! `relicario sync` — pull --rebase + push. + +use anyhow::Result; + +pub 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(()) +} diff --git a/crates/relicario-cli/src/main.rs b/crates/relicario-cli/src/main.rs index 643708e..aa7fbf3 100644 --- a/crates/relicario-cli/src/main.rs +++ b/crates/relicario-cli/src/main.rs @@ -429,10 +429,10 @@ fn main() -> Result<()> { match cli.command { Commands::Init { image, output } => commands::init::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::Get { query, show, copy } => commands::get::cmd_get(query, show, copy), + Commands::List { r#type, group, tag, trashed } => commands::list::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::History { query, show, field } => commands::list::cmd_history(query, show, field), Commands::Rm { query } => cmd_rm(query), Commands::Restore { query } => cmd_restore(query), Commands::Purge { query } => cmd_purge(query), @@ -447,8 +447,8 @@ fn main() -> Result<()> { commands::generate::cmd_generate(length, bip39, words, symbols, separator) } Commands::Settings { action } => cmd_settings(action), - Commands::Sync => cmd_sync(), - Commands::Status => cmd_status(), + Commands::Sync => commands::sync::cmd_sync(), + Commands::Status => commands::status::cmd_status(), Commands::Lock => { eprintln!("no cached session to lock"); Ok(()) } Commands::Completions { shell } => { let mut cmd = Cli::command(); @@ -467,7 +467,7 @@ fn main() -> Result<()> { /// /// 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) { +pub(crate) fn refresh_groups_cache(vault_dir: &std::path::Path, manifest: &relicario_core::Manifest) { let mut set = std::collections::BTreeSet::::new(); for entry in manifest.items.values() { if let Some(g) = entry.group.as_ref() { @@ -815,158 +815,6 @@ fn build_totp_item( Ok(item) } -fn cmd_get(query: String, show: bool, copy: bool) -> Result<()> { - use relicario_core::ItemCore; - use zeroize::Zeroizing; - - let vault = crate::session::UnlockedVault::unlock_interactive()?; - let manifest = vault.load_manifest()?; - refresh_groups_cache(vault.root(), &manifest); - let entry = resolve_query(&manifest, &query)?; - let item = vault.load_item(&entry.id)?; - - println!("ID: {}", item.id.as_str()); - println!("Title: {}", item.title); - println!("Type: {:?}", item.r#type); - if let Some(g) = &item.group { println!("Group: {g}"); } - if !item.tags.is_empty() { println!("Tags: {}", item.tags.join(", ")); } - println!("Created: {}", crate::helpers::iso8601(item.created)); - println!("Modified: {}", crate::helpers::iso8601(item.modified)); - if let Some(t) = item.trashed_at { println!("Trashed: {}", crate::helpers::iso8601(t)); } - println!(); - - let primary_secret: Option> = match &item.core { - ItemCore::Login(l) => { - if let Some(u) = &l.username { println!("Username: {u}"); } - if let Some(u) = &l.url { println!("URL: {u}"); } - if let Some(t) = &l.totp { - if show { - println!("TOTP: {}", data_encoding::BASE32.encode(&t.secret)); - } else { - println!("TOTP: **** (use --show to reveal)"); - } - } - l.password.clone() - } - 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 copy_to_clipboard_then_clear(secret: &zeroize::Zeroizing) -> Result<()> { - use arboard::Clipboard; - let mut cb = Clipboard::new().context("failed to access clipboard")?; - cb.set_text(secret.as_str().to_string()).context("failed to write clipboard")?; - let cleared_copy = zeroize::Zeroizing::new(secret.as_str().to_owned()); - // Unconditional clear (audit M6): spawn a detached thread that waits 30s - // and then rewrites the clipboard with empty string. Even if the user - // copies something else in the interim, we still overwrite once. - std::thread::spawn(move || { - std::thread::sleep(std::time::Duration::from_secs(30)); - if let Ok(mut cb) = Clipboard::new() { - let _ = cb.set_text(String::new()); - drop(cleared_copy); // zeroize the detached copy - } - }); - Ok(()) -} -fn cmd_list( - type_filter: Option, - group_filter: Option, - tag_filter: Option, - trashed: bool, -) -> Result<()> { - use relicario_core::ItemType; - - let vault = crate::session::UnlockedVault::unlock_interactive()?; - let manifest = vault.load_manifest()?; - refresh_groups_cache(vault.root(), &manifest); - - let parsed_type: Option = match type_filter.as_deref() { - None => None, - Some("login") => Some(ItemType::Login), - Some("secure_note") | Some("note") => Some(ItemType::SecureNote), - Some("identity") => Some(ItemType::Identity), - Some("card") => Some(ItemType::Card), - Some("key") => Some(ItemType::Key), - Some("document") => Some(ItemType::Document), - Some("totp") => Some(ItemType::Totp), - Some(other) => anyhow::bail!("unknown type filter: {other}"), - }; - - let mut entries: Vec<_> = manifest.items.values() - .filter(|e| { - if trashed { e.trashed_at.is_some() } else { e.trashed_at.is_none() } - }) - .filter(|e| match parsed_type { - Some(t) => e.r#type == t, - None => true, - }) - .filter(|e| group_filter.as_ref().is_none_or(|g| e.group.as_deref() == Some(g.as_str()))) - .filter(|e| tag_filter.as_ref().is_none_or(|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} TITLE", "ID", "TYPE", "FAV"); - for e in entries { - let fav = if e.favorite { " *" } else { "" }; - println!("{:<16} {:<14} {:<6} {}", e.id.as_str(), format!("{:?}", e.r#type), fav, e.title); - } - Ok(()) -} fn cmd_edit(query: String, totp_qr: Option) -> Result<()> { use relicario_core::time::now_unix; use relicario_core::ItemCore; @@ -1131,56 +979,6 @@ fn push_history( }); } -fn cmd_history(query: String, show: bool, field: Option) -> Result<()> { - let vault = crate::session::UnlockedVault::unlock_interactive()?; - let manifest = vault.load_manifest()?; - let entry = resolve_query(&manifest, &query)?; - let item = vault.load_item(&entry.id)?; - - println!("History for {} ({})", item.title, item.id.as_str()); - println!(); - - // Filter and sort the field-id keys so output is deterministic. - let mut keys: Vec<&relicario_core::FieldId> = item.field_history.keys().collect(); - keys.sort_by(|a, b| a.0.cmp(&b.0)); - - let mut printed_any = false; - for fid in keys { - let display_name = fid.0.strip_prefix("core:").unwrap_or(&fid.0); - if let Some(filter) = &field { - if display_name != filter && fid.0 != *filter { continue; } - } - let entries = &item.field_history[fid]; - if entries.is_empty() { continue; } - printed_any = true; - - println!("{display_name} ({} {})", - entries.len(), - if entries.len() == 1 { "entry" } else { "entries" }); - for (i, e) in entries.iter().enumerate() { - let ts = crate::helpers::iso8601(e.replaced_at); - if show { - println!(" [{i}] {ts} {}", e.value.as_str()); - } else { - println!(" [{i}] {ts} ********"); - } - } - println!(); - } - - if !printed_any { - if field.is_some() { - println!("no history for the requested field"); - } else { - println!("no history captured for this item"); - } - } else if !show { - println!("(use --show to reveal values)"); - } - - Ok(()) -} - fn cmd_rm(query: String) -> Result<()> { let vault = crate::session::UnlockedVault::unlock_interactive()?; let mut manifest = vault.load_manifest()?; @@ -1264,7 +1062,7 @@ fn cmd_purge(query: String) -> Result<()> { fn cmd_trash(action: TrashAction) -> Result<()> { match action { - TrashAction::List => cmd_list(None, None, None, true), + TrashAction::List => commands::list::cmd_list(None, None, None, true), TrashAction::Empty => cmd_trash_empty(), } } @@ -1942,65 +1740,6 @@ fn cmd_settings(action: SettingsAction) -> Result<()> { 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(()) -} - // ── Device management ───────────────────────────────────────────────────────── /// Build a `GiteaClient` from flags or environment variables.