diff --git a/crates/relicario-cli/src/commands/attach.rs b/crates/relicario-cli/src/commands/attach.rs new file mode 100644 index 0000000..8b99496 --- /dev/null +++ b/crates/relicario-cli/src/commands/attach.rs @@ -0,0 +1,175 @@ +//! `relicario attach` / `attachments` / `extract` / `detach` — per-attachment ops. + +use std::path::PathBuf; + +use anyhow::{Context, Result}; + +use crate::parse::guess_mime; + +pub 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 = super::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(); + super::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(()) +} + +pub fn cmd_attachments(query: String) -> 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)?; + if item.attachments.is_empty() { eprintln!("(no attachments)"); return Ok(()); } + println!("{:<17} {:>12} {:<22} FILENAME", "AID", "SIZE", "MIME"); + for a in &item.attachments { + println!("{:<17} {:>12} {:<22} {}", a.id.as_str(), a.size, a.mime_type, a.filename); + } + Ok(()) +} + +pub fn cmd_extract(query: String, aid: String, out: Option) -> Result<()> { + use std::fs; + use relicario_core::decrypt_attachment; + + let vault = crate::session::UnlockedVault::unlock_interactive()?; + let manifest = vault.load_manifest()?; + let entry = super::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(()) +} + +pub 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 = super::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()); + super::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(()) +} diff --git a/crates/relicario-cli/src/commands/mod.rs b/crates/relicario-cli/src/commands/mod.rs index d040bff..0db23df 100644 --- a/crates/relicario-cli/src/commands/mod.rs +++ b/crates/relicario-cli/src/commands/mod.rs @@ -6,6 +6,7 @@ //! this file as `pub(crate)` so siblings can pull them in via //! `use crate::commands::*`. +pub mod attach; pub mod backup; pub mod generate; pub mod get; diff --git a/crates/relicario-cli/src/main.rs b/crates/relicario-cli/src/main.rs index 8edb235..2b4c004 100644 --- a/crates/relicario-cli/src/main.rs +++ b/crates/relicario-cli/src/main.rs @@ -439,10 +439,10 @@ fn main() -> Result<()> { Commands::Trash { action } => commands::trash::cmd_trash(action), Commands::Backup { action } => commands::backup::cmd_backup(action), Commands::Import { action } => commands::import::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::Attach { query, file } => commands::attach::cmd_attach(query, file), + Commands::Attachments { query } => commands::attach::cmd_attachments(query), + Commands::Extract { query, aid, out } => commands::attach::cmd_extract(query, aid, out), + Commands::Detach { query, aid } => commands::attach::cmd_detach(query, aid), Commands::Generate { length, bip39, words, symbols, separator } => { commands::generate::cmd_generate(length, bip39, words, symbols, separator) } @@ -979,173 +979,6 @@ fn push_history( }); } -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} FILENAME", "AID", "SIZE", "MIME"); - for a in &item.attachments { - println!("{:<17} {:>12} {:<22} {}", a.id.as_str(), a.size, a.mime_type, a.filename); - } - Ok(()) -} - -fn cmd_extract(query: String, aid: String, out: Option) -> Result<()> { - use std::fs; - use relicario_core::decrypt_attachment; - - let vault = crate::session::UnlockedVault::unlock_interactive()?; - let manifest = vault.load_manifest()?; - let entry = resolve_query(&manifest, &query)?; - let item = vault.load_item(&entry.id)?; - - let aref = item.attachments.iter().find(|a| a.id.as_str() == aid) - .ok_or_else(|| anyhow::anyhow!("no attachment {aid} on {}", item.title))?; - let path = vault.root().join("attachments").join(item.id.as_str()) - .join(format!("{}.enc", aid)); - let bytes = fs::read(&path) - .with_context(|| format!("failed to read {}", path.display()))?; - let plaintext = decrypt_attachment(&bytes, vault.key())?; - let out_path = out.unwrap_or_else(|| PathBuf::from(&aref.filename)); - fs::write(&out_path, plaintext.as_slice()) - .with_context(|| format!("failed to write {}", out_path.display()))?; - eprintln!("Wrote {} bytes to {}", plaintext.len(), out_path.display()); - Ok(()) -} -fn cmd_detach(query: String, aid: String) -> Result<()> { - use std::fs; - use relicario_core::ItemCore; - use relicario_core::time::now_unix; - - let vault = crate::session::UnlockedVault::unlock_interactive()?; - let mut manifest = vault.load_manifest()?; - let entry = resolve_query(&manifest, &query)?; - let id = entry.id.clone(); - let _ = entry; - let mut item = vault.load_item(&id)?; - - let pos = item.attachments.iter().position(|a| a.id.as_str() == aid) - .ok_or_else(|| anyhow::anyhow!("no attachment {aid} on {}", item.title))?; - - // Document items keep their primary blob in the core; refuse to orphan it. - if let ItemCore::Document(d) = &item.core { - if d.primary_attachment.as_str() == aid { - anyhow::bail!( - "cannot detach the primary attachment of a Document item; \ - use `purge {}` to delete the whole item", - item.title, - ); - } - } - - let removed = item.attachments.remove(pos); - let blob_path = vault.root().join("attachments").join(item.id.as_str()) - .join(format!("{}.enc", removed.id.as_str())); - if blob_path.exists() { - fs::remove_file(&blob_path) - .with_context(|| format!("failed to delete {}", blob_path.display()))?; - } - - item.modified = now_unix(); - vault.save_item(&item)?; - manifest.upsert(&item); - vault.save_manifest(&manifest)?; - - let item_path = format!("items/{}.enc", item.id.as_str()); - let blob_relpath = format!("attachments/{}/{}.enc", item.id.as_str(), removed.id.as_str()); - commit_paths( - &vault, - &format!("detach: {} from {} ({})", crate::helpers::sanitize_for_commit(&removed.filename), crate::helpers::sanitize_for_commit(&item.title), item.id.as_str()), - &[&item_path, "manifest.enc", &blob_relpath], - )?; - eprintln!("Detached {} (aid={}) from {}", removed.filename, aid, item.title); - Ok(()) -} - fn cmd_settings(action: SettingsAction) -> Result<()> { use relicario_core::{ Capitalization, CharClasses, GeneratorRequest, HistoryRetention,