//! `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.after_manifest_change(&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.after_manifest_change(&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(()) }