Compare commits
4 Commits
feature/cl
...
feature/cl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4777cc0bb | ||
|
|
4b657e71f1 | ||
|
|
7901c2758d | ||
|
|
2e41e0bae0 |
@@ -11,7 +11,7 @@ use anyhow::{Context, Result};
|
|||||||
|
|
||||||
use crate::AddKind;
|
use crate::AddKind;
|
||||||
use crate::parse::{base32_decode_lenient, guess_mime, parse_month_year};
|
use crate::parse::{base32_decode_lenient, guess_mime, parse_month_year};
|
||||||
use crate::prompt::{prompt, prompt_optional, prompt_or_flag, prompt_or_flag_optional, prompt_secret};
|
use crate::prompt::{prompt, prompt_optional, prompt_secret};
|
||||||
|
|
||||||
pub fn cmd_add(kind: AddKind) -> Result<()> {
|
pub fn cmd_add(kind: AddKind) -> Result<()> {
|
||||||
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||||
@@ -36,8 +36,7 @@ pub fn cmd_add(kind: AddKind) -> Result<()> {
|
|||||||
|
|
||||||
vault.save_item(&item)?;
|
vault.save_item(&item)?;
|
||||||
manifest.upsert(&item);
|
manifest.upsert(&item);
|
||||||
vault.save_manifest(&manifest)?;
|
vault.after_manifest_change(&manifest)?;
|
||||||
crate::refresh_groups_cache(vault.root(), &manifest);
|
|
||||||
|
|
||||||
let mut paths: Vec<String> = vec![
|
let mut paths: Vec<String> = vec![
|
||||||
format!("items/{}.enc", item.id.as_str()),
|
format!("items/{}.enc", item.id.as_str()),
|
||||||
@@ -69,9 +68,9 @@ fn build_login_item(
|
|||||||
use relicario_core::{Item, ItemCore};
|
use relicario_core::{Item, ItemCore};
|
||||||
use zeroize::Zeroizing;
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
|
let title = title.map(Ok).unwrap_or_else(|| prompt("Title"))?;
|
||||||
let username = prompt_or_flag_optional(username, "Username", |s| Ok(s.to_string()))?;
|
let username = username.or_else(|| prompt_optional("Username").ok().flatten());
|
||||||
let url = prompt_or_flag_optional(url, "URL", |s| Ok(s.to_string()))?;
|
let url = url.or_else(|| prompt_optional("URL").ok().flatten());
|
||||||
let parsed_url = match url {
|
let parsed_url = match url {
|
||||||
Some(s) => Some(url::Url::parse(&s).with_context(|| format!("invalid URL: {s}"))?),
|
Some(s) => Some(url::Url::parse(&s).with_context(|| format!("invalid URL: {s}"))?),
|
||||||
None => None,
|
None => None,
|
||||||
@@ -115,7 +114,7 @@ fn build_secure_note_item(
|
|||||||
use relicario_core::{Item, ItemCore};
|
use relicario_core::{Item, ItemCore};
|
||||||
use zeroize::Zeroizing;
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
|
let title = title.map(Ok).unwrap_or_else(|| prompt("Title"))?;
|
||||||
let body = if body_prompt {
|
let body = if body_prompt {
|
||||||
eprintln!("Enter note body; end with Ctrl-D on a blank line:");
|
eprintln!("Enter note body; end with Ctrl-D on a blank line:");
|
||||||
let mut s = String::new();
|
let mut s = String::new();
|
||||||
@@ -144,7 +143,7 @@ fn build_identity_item(
|
|||||||
use relicario_core::item_types::IdentityCore;
|
use relicario_core::item_types::IdentityCore;
|
||||||
use relicario_core::{Item, ItemCore};
|
use relicario_core::{Item, ItemCore};
|
||||||
|
|
||||||
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
|
let title = title.map(Ok).unwrap_or_else(|| prompt("Title"))?;
|
||||||
let dob = match date_of_birth {
|
let dob = match date_of_birth {
|
||||||
Some(s) => Some(chrono::NaiveDate::parse_from_str(&s, "%Y-%m-%d")
|
Some(s) => Some(chrono::NaiveDate::parse_from_str(&s, "%Y-%m-%d")
|
||||||
.with_context(|| format!("invalid date {s} (expected YYYY-MM-DD)"))?),
|
.with_context(|| format!("invalid date {s} (expected YYYY-MM-DD)"))?),
|
||||||
@@ -170,7 +169,7 @@ fn build_card_item(
|
|||||||
use relicario_core::{Item, ItemCore};
|
use relicario_core::{Item, ItemCore};
|
||||||
use zeroize::Zeroizing;
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
|
let title = title.map(Ok).unwrap_or_else(|| prompt("Title"))?;
|
||||||
let number = Zeroizing::new(prompt_secret("Card number: ")?);
|
let number = Zeroizing::new(prompt_secret("Card number: ")?);
|
||||||
let cvv = Zeroizing::new(prompt_secret("CVV (blank to skip): ")?);
|
let cvv = Zeroizing::new(prompt_secret("CVV (blank to skip): ")?);
|
||||||
let cvv = if cvv.is_empty() { None } else { Some(cvv) };
|
let cvv = if cvv.is_empty() { None } else { Some(cvv) };
|
||||||
@@ -209,7 +208,7 @@ fn build_key_item(
|
|||||||
use relicario_core::{Item, ItemCore};
|
use relicario_core::{Item, ItemCore};
|
||||||
use zeroize::Zeroizing;
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
|
let title = title.map(Ok).unwrap_or_else(|| prompt("Title"))?;
|
||||||
eprintln!("Paste key material; end with Ctrl-D on a blank line:");
|
eprintln!("Paste key material; end with Ctrl-D on a blank line:");
|
||||||
let mut key_material = String::new();
|
let mut key_material = String::new();
|
||||||
std::io::Read::read_to_string(&mut std::io::stdin(), &mut key_material)?;
|
std::io::Read::read_to_string(&mut std::io::stdin(), &mut key_material)?;
|
||||||
@@ -236,7 +235,7 @@ fn build_document_item(
|
|||||||
use relicario_core::{encrypt_attachment, AttachmentRef, Item, ItemCore};
|
use relicario_core::{encrypt_attachment, AttachmentRef, Item, ItemCore};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
|
|
||||||
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
|
let title = title.map(Ok).unwrap_or_else(|| prompt("Title"))?;
|
||||||
let bytes = fs::read(&file)
|
let bytes = fs::read(&file)
|
||||||
.with_context(|| format!("failed to read {}", file.display()))?;
|
.with_context(|| format!("failed to read {}", file.display()))?;
|
||||||
let caps = vault.load_settings()?.attachment_caps;
|
let caps = vault.load_settings()?.attachment_caps;
|
||||||
@@ -285,7 +284,7 @@ fn build_totp_item(
|
|||||||
use relicario_core::{Item, ItemCore};
|
use relicario_core::{Item, ItemCore};
|
||||||
use zeroize::Zeroizing;
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
let title = prompt_or_flag(title, "Title", |s| Ok(s.to_string()))?;
|
let title = title.map(Ok).unwrap_or_else(|| prompt("Title"))?;
|
||||||
let secret_b32 = match secret {
|
let secret_b32 = match secret {
|
||||||
Some(s) => s,
|
Some(s) => s,
|
||||||
None => prompt_secret("TOTP secret (base32): ")?,
|
None => prompt_secret("TOTP secret (base32): ")?,
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ pub fn cmd_attach(query: String, file: PathBuf) -> Result<()> {
|
|||||||
item.modified = now_unix();
|
item.modified = now_unix();
|
||||||
vault.save_item(&item)?;
|
vault.save_item(&item)?;
|
||||||
manifest.upsert(&item);
|
manifest.upsert(&item);
|
||||||
vault.save_manifest(&manifest)?;
|
vault.after_manifest_change(&manifest)?;
|
||||||
|
|
||||||
let paths = [
|
let paths = [
|
||||||
format!("items/{}.enc", item.id.as_str()),
|
format!("items/{}.enc", item.id.as_str()),
|
||||||
@@ -161,7 +161,7 @@ pub fn cmd_detach(query: String, aid: String) -> Result<()> {
|
|||||||
item.modified = now_unix();
|
item.modified = now_unix();
|
||||||
vault.save_item(&item)?;
|
vault.save_item(&item)?;
|
||||||
manifest.upsert(&item);
|
manifest.upsert(&item);
|
||||||
vault.save_manifest(&manifest)?;
|
vault.after_manifest_change(&manifest)?;
|
||||||
|
|
||||||
let item_path = format!("items/{}.enc", item.id.as_str());
|
let item_path = format!("items/{}.enc", item.id.as_str());
|
||||||
let blob_relpath = format!("attachments/{}/{}.enc", item.id.as_str(), removed.id.as_str());
|
let blob_relpath = format!("attachments/{}/{}.enc", item.id.as_str(), removed.id.as_str());
|
||||||
|
|||||||
@@ -41,8 +41,7 @@ pub fn cmd_edit(query: String, totp_qr: Option<PathBuf>) -> Result<()> {
|
|||||||
item.modified = now_unix();
|
item.modified = now_unix();
|
||||||
vault.save_item(&item)?;
|
vault.save_item(&item)?;
|
||||||
manifest.upsert(&item);
|
manifest.upsert(&item);
|
||||||
vault.save_manifest(&manifest)?;
|
vault.after_manifest_change(&manifest)?;
|
||||||
crate::refresh_groups_cache(vault.root(), &manifest);
|
|
||||||
super::commit_paths(&vault, &format!("edit: {} ({})", crate::helpers::sanitize_for_commit(&item.title), item.id.as_str()),
|
super::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"])?;
|
&[&format!("items/{}.enc", item.id.as_str()), "manifest.enc"])?;
|
||||||
eprintln!("Updated {}", item.id.as_str());
|
eprintln!("Updated {}", item.id.as_str());
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ pub fn cmd_get(query: String, show: bool, copy: bool) -> Result<()> {
|
|||||||
|
|
||||||
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||||
let manifest = vault.load_manifest()?;
|
let manifest = vault.load_manifest()?;
|
||||||
crate::refresh_groups_cache(vault.root(), &manifest);
|
crate::helpers::refresh_groups_cache(vault.root(), &manifest);
|
||||||
let entry = super::resolve_query(&manifest, &query)?;
|
let entry = super::resolve_query(&manifest, &query)?;
|
||||||
let item = vault.load_item(&entry.id)?;
|
let item = vault.load_item(&entry.id)?;
|
||||||
|
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ fn cmd_import_lastpass(csv_path: PathBuf) -> Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
vault.save_manifest(&manifest)?;
|
vault.after_manifest_change(&manifest)?;
|
||||||
written_paths.push("manifest.enc".into());
|
written_paths.push("manifest.enc".into());
|
||||||
|
|
||||||
let path_refs: Vec<&str> = written_paths.iter().map(String::as_str).collect();
|
let path_refs: Vec<&str> = written_paths.iter().map(String::as_str).collect();
|
||||||
|
|||||||
@@ -65,17 +65,7 @@ pub fn cmd_init(image: PathBuf, output: PathBuf) -> Result<()> {
|
|||||||
fs::write(relicario_dir.join("salt"), salt)?;
|
fs::write(relicario_dir.join("salt"), salt)?;
|
||||||
fs::write(
|
fs::write(
|
||||||
relicario_dir.join("params.json"),
|
relicario_dir.join("params.json"),
|
||||||
serde_json::to_string_pretty(&ParamsFile {
|
serde_json::to_string_pretty(&crate::session::ParamsFile::for_new_vault(¶ms))?,
|
||||||
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();
|
let manifest = Manifest::new();
|
||||||
fs::write(root.join("manifest.enc"), encrypt_manifest(&manifest, &master_key)?)?;
|
fs::write(root.join("manifest.enc"), encrypt_manifest(&manifest, &master_key)?)?;
|
||||||
@@ -106,20 +96,3 @@ pub fn cmd_init(image: PathBuf, output: PathBuf) -> Result<()> {
|
|||||||
eprintln!(" \u{2192} back this file up somewhere safe; it is your second factor.");
|
eprintln!(" \u{2192} back this file up somewhere safe; it is your second factor.");
|
||||||
Ok(())
|
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,
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ pub fn cmd_list(
|
|||||||
|
|
||||||
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
let vault = crate::session::UnlockedVault::unlock_interactive()?;
|
||||||
let manifest = vault.load_manifest()?;
|
let manifest = vault.load_manifest()?;
|
||||||
crate::refresh_groups_cache(vault.root(), &manifest);
|
crate::helpers::refresh_groups_cache(vault.root(), &manifest);
|
||||||
|
|
||||||
let parsed_type: Option<ItemType> = match type_filter.as_deref() {
|
let parsed_type: Option<ItemType> = match type_filter.as_deref() {
|
||||||
None => None,
|
None => None,
|
||||||
|
|||||||
@@ -15,8 +15,7 @@ pub fn cmd_rm(query: String) -> Result<()> {
|
|||||||
item.soft_delete();
|
item.soft_delete();
|
||||||
vault.save_item(&item)?;
|
vault.save_item(&item)?;
|
||||||
manifest.upsert(&item);
|
manifest.upsert(&item);
|
||||||
vault.save_manifest(&manifest)?;
|
vault.after_manifest_change(&manifest)?;
|
||||||
crate::refresh_groups_cache(vault.root(), &manifest);
|
|
||||||
super::commit_paths(&vault, &format!("trash: {} ({})", crate::helpers::sanitize_for_commit(&item.title), item.id.as_str()),
|
super::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"])?;
|
&[&format!("items/{}.enc", item.id.as_str()), "manifest.enc"])?;
|
||||||
eprintln!("Moved to trash: {}", item.title);
|
eprintln!("Moved to trash: {}", item.title);
|
||||||
@@ -33,37 +32,41 @@ pub fn cmd_restore(query: String) -> Result<()> {
|
|||||||
item.restore();
|
item.restore();
|
||||||
vault.save_item(&item)?;
|
vault.save_item(&item)?;
|
||||||
manifest.upsert(&item);
|
manifest.upsert(&item);
|
||||||
vault.save_manifest(&manifest)?;
|
vault.after_manifest_change(&manifest)?;
|
||||||
crate::refresh_groups_cache(vault.root(), &manifest);
|
|
||||||
super::commit_paths(&vault, &format!("restore: {} ({})", crate::helpers::sanitize_for_commit(&item.title), item.id.as_str()),
|
super::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"])?;
|
&[&format!("items/{}.enc", item.id.as_str()), "manifest.enc"])?;
|
||||||
eprintln!("Restored: {}", item.title);
|
eprintln!("Restored: {}", item.title);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Inner purge: assumes vault is already unlocked and manifest is loaded.
|
/// Filesystem-only purge: removes the item.enc, attachments/<id>/, and updates
|
||||||
/// Caller is responsible for saving the manifest and committing afterwards.
|
/// the manifest in memory. Returns the relative paths the caller must stage
|
||||||
pub(super) fn purge_item(
|
/// via `git rm` after the loop. Does NOT invoke any git commands — the caller
|
||||||
|
/// batches them.
|
||||||
|
pub(super) fn purge_item_filesystem(
|
||||||
vault: &crate::session::UnlockedVault,
|
vault: &crate::session::UnlockedVault,
|
||||||
manifest: &mut relicario_core::Manifest,
|
manifest: &mut relicario_core::Manifest,
|
||||||
id: &relicario_core::ItemId,
|
id: &relicario_core::ItemId,
|
||||||
title: &str,
|
title: &str,
|
||||||
) -> Result<()> {
|
) -> Result<Vec<String>> {
|
||||||
use std::fs;
|
use std::{fs, io::ErrorKind};
|
||||||
|
|
||||||
let item_path = vault.item_path(id);
|
let item_rel = format!("items/{}.enc", id.as_str());
|
||||||
if item_path.exists() { fs::remove_file(&item_path)?; }
|
let att_rel = format!("attachments/{}", id.as_str());
|
||||||
let att_dir = vault.root().join("attachments").join(id.as_str());
|
|
||||||
if att_dir.exists() { fs::remove_dir_all(&att_dir)?; }
|
let ignore_missing = |r: std::io::Result<()>| -> Result<()> {
|
||||||
|
match r {
|
||||||
|
Ok(()) => Ok(()),
|
||||||
|
Err(e) if e.kind() == ErrorKind::NotFound => Ok(()),
|
||||||
|
Err(e) => Err(e.into()),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
ignore_missing(fs::remove_file(vault.item_path(id)))?;
|
||||||
|
ignore_missing(fs::remove_dir_all(vault.root().join("attachments").join(id.as_str())))?;
|
||||||
manifest.remove(id);
|
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}");
|
eprintln!("Purged: {title}");
|
||||||
Ok(())
|
Ok(vec![item_rel, att_rel])
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn cmd_purge(query: String) -> Result<()> {
|
pub fn cmd_purge(query: String) -> Result<()> {
|
||||||
@@ -74,12 +77,16 @@ pub fn cmd_purge(query: String) -> Result<()> {
|
|||||||
let title = entry.title.clone();
|
let title = entry.title.clone();
|
||||||
let _ = entry;
|
let _ = entry;
|
||||||
|
|
||||||
purge_item(&vault, &mut manifest, &id, &title)?;
|
let paths = purge_item_filesystem(&vault, &mut manifest, &id, &title)?;
|
||||||
vault.save_manifest(&manifest)?;
|
vault.after_manifest_change(&manifest)?;
|
||||||
crate::refresh_groups_cache(vault.root(), &manifest);
|
|
||||||
|
|
||||||
let purge_ctx = format!("purge \"{}\" ({})", title, id.as_str());
|
let purge_ctx = format!("purge \"{}\" ({})", title, id.as_str());
|
||||||
crate::helpers::git_run(vault.root(), &["add", "manifest.enc"], &format!("{purge_ctx}: git add manifest.enc"))?;
|
crate::helpers::git_rm(vault.root(), &paths, &format!("{purge_ctx}: git rm"))?;
|
||||||
|
crate::helpers::git_run(
|
||||||
|
vault.root(),
|
||||||
|
&["add", "manifest.enc"],
|
||||||
|
&format!("{purge_ctx}: git add manifest.enc"),
|
||||||
|
)?;
|
||||||
crate::helpers::git_run(
|
crate::helpers::git_run(
|
||||||
vault.root(),
|
vault.root(),
|
||||||
&["commit", "-m", &format!("purge: {} ({})", title, id.as_str())],
|
&["commit", "-m", &format!("purge: {} ({})", title, id.as_str())],
|
||||||
@@ -116,13 +123,16 @@ pub fn cmd_trash_empty() -> Result<()> {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut purged_titles = Vec::new();
|
let mut all_paths: Vec<String> = Vec::new();
|
||||||
|
let purged_count = purgeable.len();
|
||||||
for (id, title) in purgeable {
|
for (id, title) in purgeable {
|
||||||
purge_item(&vault, &mut manifest, &id, &title)?;
|
let mut paths = purge_item_filesystem(&vault, &mut manifest, &id, &title)?;
|
||||||
purged_titles.push(title);
|
all_paths.append(&mut paths);
|
||||||
}
|
}
|
||||||
|
|
||||||
vault.save_manifest(&manifest)?;
|
vault.after_manifest_change(&manifest)?;
|
||||||
|
|
||||||
|
crate::helpers::git_rm(vault.root(), &all_paths, "trash empty: git rm")?;
|
||||||
crate::helpers::git_run(
|
crate::helpers::git_run(
|
||||||
vault.root(),
|
vault.root(),
|
||||||
&["add", "manifest.enc"],
|
&["add", "manifest.enc"],
|
||||||
@@ -130,10 +140,10 @@ pub fn cmd_trash_empty() -> Result<()> {
|
|||||||
)?;
|
)?;
|
||||||
crate::helpers::git_run(
|
crate::helpers::git_run(
|
||||||
vault.root(),
|
vault.root(),
|
||||||
&["commit", "-m", &format!("trash empty: purged {} item(s)", purged_titles.len())],
|
&["commit", "-m", &format!("trash empty: purged {} item(s)", purged_count)],
|
||||||
"trash empty: git commit",
|
"trash empty: git commit",
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
eprintln!("Emptied trash: {} item(s)", purged_titles.len());
|
eprintln!("Emptied trash: {} item(s)", purged_count);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,6 +86,16 @@ pub fn git_run(repo: &Path, args: &[&str], context: &str) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Stage `paths` for removal in one `git rm -rf --ignore-unmatch` invocation.
|
||||||
|
/// `--ignore-unmatch` is load-bearing: a previous partial-write crash can
|
||||||
|
/// leave the manifest entry without the corresponding `items/<id>.enc` on
|
||||||
|
/// disk, and we want the rm to succeed regardless.
|
||||||
|
pub fn git_rm(repo: &Path, paths: &[String], context: &str) -> Result<()> {
|
||||||
|
let mut args: Vec<&str> = vec!["rm", "-rf", "--ignore-unmatch"];
|
||||||
|
args.extend(paths.iter().map(String::as_str));
|
||||||
|
git_run(repo, &args, context)
|
||||||
|
}
|
||||||
|
|
||||||
/// Format a Unix-seconds timestamp as an ISO-8601 UTC string.
|
/// Format a Unix-seconds timestamp as an ISO-8601 UTC string.
|
||||||
/// Audit M11: replaces the old `now_iso8601` helper that actually returned
|
/// Audit M11: replaces the old `now_iso8601` helper that actually returned
|
||||||
/// a numeric string.
|
/// a numeric string.
|
||||||
@@ -126,6 +136,24 @@ pub fn groups_cache_path(vault_dir: &Path) -> PathBuf {
|
|||||||
vault_dir.join(".relicario").join("groups.cache")
|
vault_dir.join(".relicario").join("groups.cache")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
pub fn refresh_groups_cache(vault_dir: &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 _ = write_groups_cache(vault_dir, &set);
|
||||||
|
}
|
||||||
|
|
||||||
/// Write the sorted set of group names to `<vault_dir>/.relicario/groups.cache`,
|
/// Write the sorted set of group names to `<vault_dir>/.relicario/groups.cache`,
|
||||||
/// one name per line. In debug builds, setting `RELICARIO_NO_GROUPS_CACHE`
|
/// one name per line. In debug builds, setting `RELICARIO_NO_GROUPS_CACHE`
|
||||||
/// suppresses the write (developer debugging tool). In release builds the env
|
/// suppresses the write (developer debugging tool). In release builds the env
|
||||||
|
|||||||
@@ -457,24 +457,6 @@ fn main() -> Result<()> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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.
|
|
||||||
pub(crate) 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).
|
/// Check for test passphrase override (debug builds only; stripped from release).
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
pub(crate) fn test_passphrase_override() -> Option<String> {
|
pub(crate) fn test_passphrase_override() -> Option<String> {
|
||||||
|
|||||||
@@ -5,12 +5,8 @@
|
|||||||
//! used by the edit handlers to keep current values when the user hits enter
|
//! used by the edit handlers to keep current values when the user hits enter
|
||||||
//! at a blank prompt. `prompt_secret` honours `RELICARIO_TEST_ITEM_SECRET`
|
//! at a blank prompt. `prompt_secret` honours `RELICARIO_TEST_ITEM_SECRET`
|
||||||
//! so integration tests (which don't have a TTY) can inject secrets.
|
//! so integration tests (which don't have a TTY) can inject secrets.
|
||||||
//! `prompt_or_flag` and `prompt_or_flag_optional` thread a CLI-flag value
|
|
||||||
//! through the same path so command handlers can use one call site whether
|
|
||||||
//! the value came from the command line or from an interactive prompt.
|
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use std::io::BufRead;
|
|
||||||
|
|
||||||
/// `rpassword::prompt_password` wrapper that honours `RELICARIO_TEST_ITEM_SECRET`
|
/// `rpassword::prompt_password` wrapper that honours `RELICARIO_TEST_ITEM_SECRET`
|
||||||
/// for integration-test use (rpassword reads /dev/tty by default, which is
|
/// for integration-test use (rpassword reads /dev/tty by default, which is
|
||||||
@@ -22,37 +18,25 @@ pub(crate) fn prompt_secret(label: &str) -> Result<String> {
|
|||||||
rpassword::prompt_password(label).map_err(Into::into)
|
rpassword::prompt_password(label).map_err(Into::into)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read_required_line<R: BufRead>(reader: &mut R, label: &str) -> Result<String> {
|
pub(crate) fn prompt(label: &str) -> Result<String> {
|
||||||
eprint!("{label}: ");
|
eprint!("{label}: ");
|
||||||
std::io::Write::flush(&mut std::io::stderr())?;
|
std::io::Write::flush(&mut std::io::stderr())?;
|
||||||
let mut s = String::new();
|
let mut s = String::new();
|
||||||
reader.read_line(&mut s)?;
|
std::io::stdin().read_line(&mut s)?;
|
||||||
let trimmed = s.trim().to_string();
|
let trimmed = s.trim().to_string();
|
||||||
if trimmed.is_empty() { anyhow::bail!("{label} required"); }
|
if trimmed.is_empty() { anyhow::bail!("{label} required"); }
|
||||||
Ok(trimmed)
|
Ok(trimmed)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read_optional_line<R: BufRead>(reader: &mut R, label: &str) -> Result<Option<String>> {
|
pub(crate) fn prompt_optional(label: &str) -> Result<Option<String>> {
|
||||||
eprint!("{label} (leave blank to skip): ");
|
eprint!("{label} (leave blank to skip): ");
|
||||||
std::io::Write::flush(&mut std::io::stderr())?;
|
std::io::Write::flush(&mut std::io::stderr())?;
|
||||||
let mut s = String::new();
|
let mut s = String::new();
|
||||||
reader.read_line(&mut s)?;
|
std::io::stdin().read_line(&mut s)?;
|
||||||
let trimmed = s.trim().to_string();
|
let trimmed = s.trim().to_string();
|
||||||
Ok(if trimmed.is_empty() { None } else { Some(trimmed) })
|
Ok(if trimmed.is_empty() { None } else { Some(trimmed) })
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn prompt(label: &str) -> Result<String> {
|
|
||||||
let stdin = std::io::stdin();
|
|
||||||
let mut reader = std::io::BufReader::new(stdin.lock());
|
|
||||||
read_required_line(&mut reader, label)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn prompt_optional(label: &str) -> Result<Option<String>> {
|
|
||||||
let stdin = std::io::stdin();
|
|
||||||
let mut reader = std::io::BufReader::new(stdin.lock());
|
|
||||||
read_optional_line(&mut reader, label)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn prompt_keep(label: &str, current: &str) -> Result<Option<String>> {
|
pub(crate) fn prompt_keep(label: &str, current: &str) -> Result<Option<String>> {
|
||||||
eprint!("{label} [{current}]: ");
|
eprint!("{label} [{current}]: ");
|
||||||
std::io::Write::flush(&mut std::io::stderr())?;
|
std::io::Write::flush(&mut std::io::stderr())?;
|
||||||
@@ -79,117 +63,3 @@ pub(crate) fn prompt_yesno(label: &str) -> Result<bool> {
|
|||||||
std::io::stdin().read_line(&mut s)?;
|
std::io::stdin().read_line(&mut s)?;
|
||||||
Ok(matches!(s.trim().to_ascii_lowercase().as_str(), "y" | "yes"))
|
Ok(matches!(s.trim().to_ascii_lowercase().as_str(), "y" | "yes"))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn prompt_or_flag<T>(
|
|
||||||
flag: Option<T>,
|
|
||||||
label: &str,
|
|
||||||
parser: impl FnOnce(&str) -> Result<T>,
|
|
||||||
) -> Result<T> {
|
|
||||||
let stdin = std::io::stdin();
|
|
||||||
let mut reader = std::io::BufReader::new(stdin.lock());
|
|
||||||
prompt_or_flag_with_reader(flag, label, parser, &mut reader)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn prompt_or_flag_optional<T>(
|
|
||||||
flag: Option<T>,
|
|
||||||
label: &str,
|
|
||||||
parser: impl FnOnce(&str) -> Result<T>,
|
|
||||||
) -> Result<Option<T>> {
|
|
||||||
let stdin = std::io::stdin();
|
|
||||||
let mut reader = std::io::BufReader::new(stdin.lock());
|
|
||||||
prompt_or_flag_optional_with_reader(flag, label, parser, &mut reader)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn prompt_or_flag_with_reader<T, R: BufRead>(
|
|
||||||
flag: Option<T>,
|
|
||||||
label: &str,
|
|
||||||
parser: impl FnOnce(&str) -> Result<T>,
|
|
||||||
reader: &mut R,
|
|
||||||
) -> Result<T> {
|
|
||||||
if let Some(t) = flag {
|
|
||||||
return Ok(t);
|
|
||||||
}
|
|
||||||
let line = read_required_line(reader, label)?;
|
|
||||||
parser(&line)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn prompt_or_flag_optional_with_reader<T, R: BufRead>(
|
|
||||||
flag: Option<T>,
|
|
||||||
label: &str,
|
|
||||||
parser: impl FnOnce(&str) -> Result<T>,
|
|
||||||
reader: &mut R,
|
|
||||||
) -> Result<Option<T>> {
|
|
||||||
if let Some(t) = flag {
|
|
||||||
return Ok(Some(t));
|
|
||||||
}
|
|
||||||
match read_optional_line(reader, label)? {
|
|
||||||
None => Ok(None),
|
|
||||||
Some(line) => parser(&line).map(Some),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use std::io::Cursor;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn prompt_or_flag_uses_flag_value_when_some() {
|
|
||||||
let mut reader = Cursor::new(Vec::<u8>::new());
|
|
||||||
let got = prompt_or_flag_with_reader::<String, _>(
|
|
||||||
Some("from-flag".to_string()),
|
|
||||||
"Title",
|
|
||||||
|_| panic!("parser must not run when flag is Some"),
|
|
||||||
&mut reader,
|
|
||||||
).expect("flag value path should succeed");
|
|
||||||
assert_eq!(got, "from-flag");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn prompt_or_flag_prompts_when_none() {
|
|
||||||
let mut reader = Cursor::new(b"prompted\n".to_vec());
|
|
||||||
let got = prompt_or_flag_with_reader::<String, _>(
|
|
||||||
None,
|
|
||||||
"Title",
|
|
||||||
|s| Ok(s.to_string()),
|
|
||||||
&mut reader,
|
|
||||||
).expect("prompt path should succeed");
|
|
||||||
assert_eq!(got, "prompted");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn prompt_or_flag_optional_returns_some_from_flag_without_reading() {
|
|
||||||
let mut reader = Cursor::new(Vec::<u8>::new());
|
|
||||||
let got = prompt_or_flag_optional_with_reader::<String, _>(
|
|
||||||
Some("flag-val".to_string()),
|
|
||||||
"URL",
|
|
||||||
|_| panic!("parser must not run when flag is Some"),
|
|
||||||
&mut reader,
|
|
||||||
).expect("flag value path should succeed");
|
|
||||||
assert_eq!(got, Some("flag-val".to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn prompt_or_flag_optional_prompts_and_blank_yields_none() {
|
|
||||||
let mut reader = Cursor::new(b"\n".to_vec());
|
|
||||||
let got = prompt_or_flag_optional_with_reader::<String, _>(
|
|
||||||
None,
|
|
||||||
"URL",
|
|
||||||
|_| panic!("parser must not run on blank input"),
|
|
||||||
&mut reader,
|
|
||||||
).expect("blank prompt should succeed with None");
|
|
||||||
assert_eq!(got, None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn prompt_or_flag_optional_prompts_and_value_runs_parser() {
|
|
||||||
let mut reader = Cursor::new(b" 42 \n".to_vec());
|
|
||||||
let got = prompt_or_flag_optional_with_reader::<u32, _>(
|
|
||||||
None,
|
|
||||||
"Number",
|
|
||||||
|s| s.parse::<u32>().map_err(Into::into),
|
|
||||||
&mut reader,
|
|
||||||
).expect("value should parse");
|
|
||||||
assert_eq!(got, Some(42));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -69,9 +69,15 @@ impl UnlockedVault {
|
|||||||
Ok(decrypt_manifest(&bytes, &self.master_key)?)
|
Ok(decrypt_manifest(&bytes, &self.master_key)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn save_manifest(&self, manifest: &Manifest) -> Result<()> {
|
/// Save the manifest and refresh the plaintext groups.cache. This is the
|
||||||
|
/// canonical "I just mutated the manifest" funnel — every command that
|
||||||
|
/// changes the manifest goes through this method, so cache freshness is
|
||||||
|
/// a compile-time invariant rather than a discipline rule.
|
||||||
|
pub fn after_manifest_change(&self, manifest: &Manifest) -> Result<()> {
|
||||||
let bytes = encrypt_manifest(manifest, &self.master_key)?;
|
let bytes = encrypt_manifest(manifest, &self.master_key)?;
|
||||||
atomic_write(&self.manifest_path(), &bytes)
|
atomic_write(&self.manifest_path(), &bytes)?;
|
||||||
|
crate::helpers::refresh_groups_cache(&self.root, manifest);
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn load_settings(&self) -> Result<VaultSettings> {
|
pub fn load_settings(&self) -> Result<VaultSettings> {
|
||||||
@@ -107,17 +113,52 @@ fn read_salt(root: &Path) -> Result<[u8; 32]> {
|
|||||||
Ok(salt)
|
Ok(salt)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read_params(root: &Path) -> Result<KdfParams> {
|
#[derive(serde::Serialize, serde::Deserialize)]
|
||||||
// params.json layout: { "format_version": 2, "kdf": { "argon2_m": ..., ... }, ... }
|
pub(crate) struct ParamsFile {
|
||||||
// We extract only the "kdf" sub-object and deserialize it as KdfParams.
|
pub format_version: u32,
|
||||||
#[derive(serde::Deserialize)]
|
pub kdf: ParamsKdf,
|
||||||
struct ParamsFile {
|
pub aead: String,
|
||||||
kdf: KdfParams,
|
pub salt_path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Serialize, serde::Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub(crate) struct ParamsKdf {
|
||||||
|
pub algorithm: String,
|
||||||
|
pub argon2_m: u32,
|
||||||
|
pub argon2_t: u32,
|
||||||
|
pub argon2_p: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ParamsFile {
|
||||||
|
pub fn for_new_vault(params: &KdfParams) -> Self {
|
||||||
|
Self {
|
||||||
|
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(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn to_kdf_params(&self) -> KdfParams {
|
||||||
|
KdfParams {
|
||||||
|
argon2_m: self.kdf.argon2_m,
|
||||||
|
argon2_t: self.kdf.argon2_t,
|
||||||
|
argon2_p: self.kdf.argon2_p,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_params(root: &Path) -> Result<KdfParams> {
|
||||||
let s = fs::read_to_string(root.join(".relicario").join("params.json"))
|
let s = fs::read_to_string(root.join(".relicario").join("params.json"))
|
||||||
.context("failed to read .relicario/params.json")?;
|
.context("failed to read .relicario/params.json")?;
|
||||||
let pf: ParamsFile = serde_json::from_str(&s).context("failed to parse params.json")?;
|
let pf: ParamsFile = serde_json::from_str(&s).context("failed to parse params.json")?;
|
||||||
Ok(pf.kdf)
|
Ok(pf.to_kdf_params())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Locate the reference image path via `RELICARIO_IMAGE` env var or interactive prompt.
|
/// Locate the reference image path via `RELICARIO_IMAGE` env var or interactive prompt.
|
||||||
@@ -149,3 +190,78 @@ fn atomic_write(path: &Path, data: &[u8]) -> Result<()> {
|
|||||||
fs::rename(&tmp, path).with_context(|| format!("failed to rename {}", path.display()))?;
|
fs::rename(&tmp, path).with_context(|| format!("failed to rename {}", path.display()))?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
const FIXTURE: &str = r#"{
|
||||||
|
"format_version": 2,
|
||||||
|
"kdf": {
|
||||||
|
"algorithm": "argon2id-v0x13",
|
||||||
|
"argon2_m": 65536,
|
||||||
|
"argon2_t": 3,
|
||||||
|
"argon2_p": 4
|
||||||
|
},
|
||||||
|
"aead": "xchacha20poly1305",
|
||||||
|
"salt_path": ".relicario/salt"
|
||||||
|
}"#;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn params_file_round_trips_current_layout() {
|
||||||
|
let pf: ParamsFile = serde_json::from_str(FIXTURE).expect("parse fixture");
|
||||||
|
assert_eq!(pf.format_version, 2);
|
||||||
|
assert_eq!(pf.kdf.algorithm, "argon2id-v0x13");
|
||||||
|
assert_eq!(pf.kdf.argon2_m, 65536);
|
||||||
|
assert_eq!(pf.kdf.argon2_t, 3);
|
||||||
|
assert_eq!(pf.kdf.argon2_p, 4);
|
||||||
|
assert_eq!(pf.aead, "xchacha20poly1305");
|
||||||
|
assert_eq!(pf.salt_path, ".relicario/salt");
|
||||||
|
|
||||||
|
let kdf = pf.to_kdf_params();
|
||||||
|
assert_eq!(kdf.argon2_m, 65536);
|
||||||
|
assert_eq!(kdf.argon2_t, 3);
|
||||||
|
assert_eq!(kdf.argon2_p, 4);
|
||||||
|
|
||||||
|
let serialized = serde_json::to_string(&pf).expect("re-serialize");
|
||||||
|
let pf2: ParamsFile = serde_json::from_str(&serialized).expect("parse re-serialized");
|
||||||
|
assert_eq!(pf2.format_version, 2);
|
||||||
|
assert_eq!(pf2.kdf.algorithm, "argon2id-v0x13");
|
||||||
|
assert_eq!(pf2.kdf.argon2_m, 65536);
|
||||||
|
assert_eq!(pf2.kdf.argon2_t, 3);
|
||||||
|
assert_eq!(pf2.kdf.argon2_p, 4);
|
||||||
|
assert_eq!(pf2.aead, "xchacha20poly1305");
|
||||||
|
assert_eq!(pf2.salt_path, ".relicario/salt");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn for_new_vault_produces_expected_shape() {
|
||||||
|
let params = KdfParams { argon2_m: 65536, argon2_t: 3, argon2_p: 4 };
|
||||||
|
let pf = ParamsFile::for_new_vault(¶ms);
|
||||||
|
let v = serde_json::to_value(&pf).expect("to_value");
|
||||||
|
assert_eq!(v["format_version"], 2);
|
||||||
|
assert_eq!(v["kdf"]["algorithm"], "argon2id-v0x13");
|
||||||
|
assert_eq!(v["kdf"]["argon2_m"], 65536);
|
||||||
|
assert_eq!(v["kdf"]["argon2_t"], 3);
|
||||||
|
assert_eq!(v["kdf"]["argon2_p"], 4);
|
||||||
|
assert_eq!(v["aead"], "xchacha20poly1305");
|
||||||
|
assert_eq!(v["salt_path"], ".relicario/salt");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn after_manifest_change_writes_manifest_and_groups_cache() {
|
||||||
|
let dir = tempfile::TempDir::new().unwrap();
|
||||||
|
let root = dir.path().to_path_buf();
|
||||||
|
std::fs::create_dir_all(root.join(".relicario")).unwrap();
|
||||||
|
std::fs::create_dir_all(root.join("items")).unwrap();
|
||||||
|
let vault = UnlockedVault {
|
||||||
|
root: root.clone(),
|
||||||
|
master_key: Zeroizing::new([0u8; 32]),
|
||||||
|
};
|
||||||
|
let manifest = Manifest::new();
|
||||||
|
|
||||||
|
vault.after_manifest_change(&manifest).unwrap();
|
||||||
|
assert!(root.join("manifest.enc").exists());
|
||||||
|
assert!(root.join(".relicario/groups.cache").exists());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -109,6 +109,72 @@ fn rm_restore_purge_cycle() {
|
|||||||
assert!(!String::from_utf8(out.stdout).unwrap().contains("target"));
|
assert!(!String::from_utf8(out.stdout).unwrap().contains("target"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn trash_empty_batches_into_one_commit() {
|
||||||
|
let v = TestVault::init();
|
||||||
|
|
||||||
|
// Add 3 items.
|
||||||
|
for title in ["alpha", "bravo", "charlie"] {
|
||||||
|
let out = v.run(&[
|
||||||
|
"add", "login",
|
||||||
|
"--title", title,
|
||||||
|
"--username", "u",
|
||||||
|
"--password", "p",
|
||||||
|
]);
|
||||||
|
assert!(out.status.success(), "add {title} failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Soft-delete all 3.
|
||||||
|
for title in ["alpha", "bravo", "charlie"] {
|
||||||
|
let out = v.run(&["rm", title]);
|
||||||
|
assert!(out.status.success(), "rm {title} failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set retention to 0 days so the recently-trashed items become purgeable
|
||||||
|
// (should_purge: now - trashed_at > 0 * 86400 = 0).
|
||||||
|
let out = v.run(&["settings", "trash-retention", "--days", "0"]);
|
||||||
|
assert!(out.status.success(), "settings trash-retention failed");
|
||||||
|
|
||||||
|
// should_purge uses strict > on (now - trashed_at), so equal-second
|
||||||
|
// timestamps don't qualify.
|
||||||
|
std::thread::sleep(std::time::Duration::from_secs(1));
|
||||||
|
|
||||||
|
// Count commits before.
|
||||||
|
let before = std::process::Command::new("git")
|
||||||
|
.args(["rev-list", "--count", "HEAD"])
|
||||||
|
.current_dir(v.path())
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
let before_count: u32 = String::from_utf8(before.stdout).unwrap().trim().parse().unwrap();
|
||||||
|
|
||||||
|
// Run trash empty.
|
||||||
|
let out = v.run(&["trash", "empty"]);
|
||||||
|
assert!(out.status.success(), "trash empty failed: stderr={}",
|
||||||
|
String::from_utf8_lossy(&out.stderr));
|
||||||
|
|
||||||
|
// Count commits after.
|
||||||
|
let after = std::process::Command::new("git")
|
||||||
|
.args(["rev-list", "--count", "HEAD"])
|
||||||
|
.current_dir(v.path())
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
let after_count: u32 = String::from_utf8(after.stdout).unwrap().trim().parse().unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
after_count - before_count, 1,
|
||||||
|
"trash empty should fire exactly one commit; before={before_count} after={after_count}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// The remaining `list --trashed` should be empty.
|
||||||
|
let out = v.run(&["list", "--trashed"]);
|
||||||
|
let stdout = String::from_utf8(out.stdout).unwrap();
|
||||||
|
let stderr = String::from_utf8(out.stderr).unwrap();
|
||||||
|
assert!(
|
||||||
|
!stdout.contains("alpha") && !stdout.contains("bravo") && !stdout.contains("charlie"),
|
||||||
|
"items still in trashed list: stdout={stdout} stderr={stderr}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn generate_random_and_bip39() {
|
fn generate_random_and_bip39() {
|
||||||
let dir = tempfile::TempDir::new().unwrap();
|
let dir = tempfile::TempDir::new().unwrap();
|
||||||
|
|||||||
Reference in New Issue
Block a user