feat(cli/org): org rm/restore/purge trash lifecycle (collection-scoped)

This commit is contained in:
adlee-was-taken
2026-06-20 14:39:18 -04:00
parent 057a7defe5
commit 6123d8b033
4 changed files with 350 additions and 4 deletions

View File

@@ -1044,6 +1044,94 @@ pub fn run_edit(
Ok(())
}
/// Resolve a query to (collection, item) with grant enforcement. Used by the
/// trash-lifecycle commands.
fn open_org_item(
vault: &crate::org_session::UnlockedOrgVault,
caller: &relicario_core::OrgMember,
query: &str,
) -> Result<(String, relicario_core::Item)> {
let manifest = vault.load_manifest()?;
let visible = manifest.filter_for_member(caller);
let entry = resolve_org_query(&visible, query)?;
let collection = entry.collection.clone();
let id = entry.id.clone();
crate::org_session::UnlockedOrgVault::ensure_grant(caller, &collection)?;
let item = vault.load_item(&collection, &id)?;
Ok((collection, item))
}
pub fn run_rm(dir: &Path, query: &str) -> Result<()> {
let vault = crate::org_session::open_org_vault(Some(dir))?;
let caller = vault.current_member()?;
let (collection, mut item) = open_org_item(&vault, &caller, query)?;
item.soft_delete();
let item_rel = vault.save_item(&collection, &item)?;
let mut manifest = vault.load_manifest()?;
upsert_org_entry(&mut manifest, &item, &collection);
vault.save_manifest(&manifest)?;
let commit_msg = format!(
"org trash: {} ({})\n\nRelicario-Actor: {} {}\nRelicario-Action: item-delete\nRelicario-Collection: {}\nRelicario-Item: {}",
crate::helpers::sanitize_for_commit(&item.title), item.id.as_str(),
caller.display_name, caller.member_id.as_str(), collection, item.id.as_str()
);
crate::org_session::org_git_run(&vault.root, &["add", &item_rel, "manifest.enc"], "org rm: git add")?;
crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "org rm: git commit")?;
println!("Moved to trash: {}", item.title);
Ok(())
}
pub fn run_restore(dir: &Path, query: &str) -> Result<()> {
let vault = crate::org_session::open_org_vault(Some(dir))?;
let caller = vault.current_member()?;
let (collection, mut item) = open_org_item(&vault, &caller, query)?;
item.restore();
let item_rel = vault.save_item(&collection, &item)?;
let mut manifest = vault.load_manifest()?;
upsert_org_entry(&mut manifest, &item, &collection);
vault.save_manifest(&manifest)?;
let commit_msg = format!(
"org restore: {} ({})\n\nRelicario-Actor: {} {}\nRelicario-Action: item-restore\nRelicario-Collection: {}\nRelicario-Item: {}",
crate::helpers::sanitize_for_commit(&item.title), item.id.as_str(),
caller.display_name, caller.member_id.as_str(), collection, item.id.as_str()
);
crate::org_session::org_git_run(&vault.root, &["add", &item_rel, "manifest.enc"], "org restore: git add")?;
crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "org restore: git commit")?;
println!("Restored: {}", item.title);
Ok(())
}
pub fn run_purge(dir: &Path, query: &str) -> Result<()> {
let vault = crate::org_session::open_org_vault(Some(dir))?;
let caller = vault.current_member()?;
let (collection, item) = open_org_item(&vault, &caller, query)?;
let title = item.title.clone();
let id = item.id.clone();
// Remove the blob from disk, drop the manifest entry, stage with git rm.
vault.remove_item(&collection, &id)?;
let mut manifest = vault.load_manifest()?;
manifest.entries.retain(|e| e.id != id);
vault.save_manifest(&manifest)?;
let item_rel = format!("items/{}/{}.enc", collection, id.as_str());
crate::helpers::git_rm(&vault.root, &[item_rel], "org purge: git rm")?;
crate::org_session::org_git_run(&vault.root, &["add", "manifest.enc"], "org purge: git add manifest")?;
let commit_msg = format!(
"org purge: {} ({})\n\nRelicario-Actor: {} {}\nRelicario-Action: item-purge\nRelicario-Collection: {}\nRelicario-Item: {}",
crate::helpers::sanitize_for_commit(&title), id.as_str(),
caller.display_name, caller.member_id.as_str(), collection, id.as_str()
);
crate::org_session::org_git_run(&vault.root, &["commit", "-m", &commit_msg], "org purge: git commit")?;
println!("Purged: {title}");
Ok(())
}
/// Insert-or-replace an `OrgManifestEntry` mirroring the personal-vault
/// `Manifest::upsert`. Keyed by item id.
fn upsert_org_entry(

View File

@@ -535,8 +535,12 @@ pub(crate) enum OrgCommands {
#[arg(long)] phone: Option<String>,
#[arg(long)] full_name: Option<String>,
},
// Item subcommands (Rm/Restore/Purge) are added by
// Task B13, which extends this enum.
/// Soft-delete an org item (reversible via `org restore`).
Rm { query: String },
/// Restore a soft-deleted org item.
Restore { query: String },
/// Permanently purge an org item (deletes the encrypted blob).
Purge { query: String },
}
#[derive(clap::Subcommand)]
@@ -689,8 +693,18 @@ fn main() -> Result<()> {
let d = crate::org_session::org_dir(dir_path)?;
commands::org::run_edit(&d, &query, title, username, url, password, body, email, phone, full_name)?;
}
// Item dispatch arms (Rm/Restore/Purge) added by
// Task B13.
OrgCommands::Rm { query } => {
let d = crate::org_session::org_dir(dir_path)?;
commands::org::run_rm(&d, &query)?;
}
OrgCommands::Restore { query } => {
let d = crate::org_session::org_dir(dir_path)?;
commands::org::run_restore(&d, &query)?;
}
OrgCommands::Purge { query } => {
let d = crate::org_session::org_dir(dir_path)?;
commands::org::run_purge(&d, &query)?;
}
}
Ok(())
}

View File

@@ -177,3 +177,41 @@ fn org_edit_updates_fields_and_commits_update_trailer() {
assert!(body.contains("Relicario-Action: item-update"), "missing update trailer: {body}");
assert!(body.contains("Relicario-Collection: prod"), "missing collection trailer: {body}");
}
#[test]
fn org_rm_restore_purge_cycle() {
let f = OrgFixture::new();
let owner = f.owner_member_id();
assert!(f.run(&["org", "create-collection", "prod", "--name", "Production"]).status.success());
assert!(f.run(&["org", "grant", &owner, "prod"]).status.success());
assert!(f.run(&[
"org", "add", "secure-note", "--collection", "prod",
"--title", "Recovery", "--body", "codes-here",
]).status.success());
// rm → appears only with --trashed.
assert!(f.run(&["org", "rm", "Recovery"]).status.success());
let listed = String::from_utf8_lossy(&f.run(&["org", "list"]).stdout).to_string();
assert!(!listed.contains("Recovery"), "trashed item still in default list: {listed}");
let trashed = String::from_utf8_lossy(&f.run(&["org", "list", "--trashed"]).stdout).to_string();
assert!(trashed.contains("Recovery"), "trashed item not in --trashed list: {trashed}");
// restore → back in default list.
assert!(f.run(&["org", "restore", "Recovery"]).status.success());
let listed = String::from_utf8_lossy(&f.run(&["org", "list"]).stdout).to_string();
assert!(listed.contains("Recovery"), "restore did not bring it back: {listed}");
// purge → blob gone, entry gone, item-purge trailer.
assert!(f.run(&["org", "purge", "Recovery"]).status.success());
let prod_dir = f.vault_path().join("items").join("prod");
let count = std::fs::read_dir(&prod_dir).map(|d| d.count()).unwrap_or(0);
assert_eq!(count, 0, "blob not purged from items/prod/");
let listed = String::from_utf8_lossy(&f.run(&["org", "list", "--trashed"]).stdout).to_string();
assert!(!listed.contains("Recovery"), "purged item still listed: {listed}");
let log = Command::new("git")
.args(["-C", f.vault_str(), "log", "-1", "--format=%B"])
.output().unwrap();
let body = String::from_utf8_lossy(&log.stdout).to_string();
assert!(body.contains("Relicario-Action: item-purge"), "missing purge trailer: {body}");
}

View File

@@ -0,0 +1,206 @@
use assert_cmd::cargo::CommandCargoExt as _;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use tempfile::TempDir;
/// A device home + an org vault. A second device can be wired for multi-member.
struct Dev {
xdg: PathBuf,
_config: TempDir,
}
impl Dev {
fn new(name: &str) -> Self {
let config = TempDir::new().unwrap();
let xdg = config.path().to_path_buf();
let devices = xdg.join("relicario").join("devices").join(name);
std::fs::create_dir_all(&devices).unwrap();
let keyfile = devices.join("signing.key");
let st = Command::new("ssh-keygen")
.args(["-t", "ed25519", "-N", "", "-C", "relicario-test", "-f"])
.arg(&keyfile)
.stdout(Stdio::null()).stderr(Stdio::null())
.status().expect("ssh-keygen");
assert!(st.success());
std::fs::rename(devices.join("signing.key.pub"), devices.join("signing.pub")).unwrap();
std::fs::write(xdg.join("relicario").join("devices").join("current"), format!("{name}\n")).unwrap();
Dev { xdg, _config: config }
}
fn pubkey(&self, name: &str) -> String {
std::fs::read_to_string(
self.xdg.join("relicario").join("devices").join(name).join("signing.pub"),
).unwrap().trim().to_string()
}
fn run(&self, vault: &Path, args: &[&str]) -> std::process::Output {
let mut cmd = Command::cargo_bin("relicario").unwrap();
cmd.env("XDG_CONFIG_HOME", &self.xdg)
.env("RELICARIO_ORG_DIR", vault)
.args(args)
.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped());
cmd.output().unwrap()
}
}
fn owner_member_id(vault: &Path) -> String {
let s = std::fs::read_to_string(vault.join("members.json")).unwrap();
let v: serde_json::Value = serde_json::from_str(&s).unwrap();
v["members"][0]["member_id"].as_str().unwrap().to_string()
}
/// Set up an org with the owner granted `prod` and one login item in it.
fn setup_with_item() -> (Dev, TempDir, String) {
let dev = Dev::new("laptop");
let vault = TempDir::new().unwrap();
let v = vault.path();
assert!(dev.run(v, &["org", "init", "--dir", v.to_str().unwrap(), "--name", "Acme"]).status.success());
let owner = owner_member_id(v);
assert!(dev.run(v, &["org", "create-collection", "prod", "--name", "Prod"]).status.success());
assert!(dev.run(v, &["org", "grant", &owner, "prod"]).status.success());
assert!(dev.run(v, &[
"org", "add", "login", "--collection", "prod",
"--title", "GitHub", "--username", "alice", "--password", "hunter2",
]).status.success());
(dev, vault, owner)
}
// (b) audit --format json parses + has expected actions.
#[test]
fn audit_format_json_is_valid_and_has_actions() {
let (dev, vault, _owner) = setup_with_item();
let out = dev.run(vault.path(), &["org", "audit", "--format", "json"]);
assert!(out.status.success(), "audit json: {}", String::from_utf8_lossy(&out.stderr));
let stdout = String::from_utf8_lossy(&out.stdout);
let events: serde_json::Value = serde_json::from_str(&stdout).expect("audit json must parse");
let arr = events.as_array().expect("array");
let actions: Vec<&str> = arr.iter()
.filter_map(|e| e["action"].as_str())
.collect();
assert!(actions.contains(&"org-init"), "actions: {actions:?}");
assert!(actions.contains(&"collection-create"), "actions: {actions:?}");
assert!(actions.contains(&"item-create"), "actions: {actions:?}");
// Honest signer attribution: none of these should be TAMPERED (signer == trailer).
assert!(arr.iter().all(|e| e["tampered"] == serde_json::Value::Bool(false)));
}
// (a) a forged-trailer commit is flagged TAMPERED.
#[test]
fn forged_trailer_commit_is_flagged_tampered() {
let (dev, vault, owner) = setup_with_item();
let v = vault.path();
// Hand-craft a SIGNED commit whose trailer CLAIMS a different actor id than
// the real signer. We reuse the org repo's own signing config (set by
// `org init`), so the commit verifies — but the trailer lies.
std::fs::write(v.join("decoy.txt"), "x").unwrap();
let git = |args: &[&str]| {
Command::new("git").current_dir(v).args(args)
.env("XDG_CONFIG_HOME", &dev.xdg)
.output().unwrap()
};
assert!(git(&["add", "decoy.txt"]).status.success());
let forged_msg = format!(
"forged\n\nRelicario-Actor: impostor ffffffffffffffff\nRelicario-Action: item-update\nRelicario-Member: {owner}"
);
// commit -S uses the repo's configured signing key (the real owner key).
let c = git(&["commit", "-S", "-m", &forged_msg]);
assert!(c.status.success(), "forged commit: {}", String::from_utf8_lossy(&c.stderr));
let out = dev.run(v, &["org", "audit", "--format", "json"]);
let events: serde_json::Value =
serde_json::from_str(&String::from_utf8_lossy(&out.stdout)).unwrap();
let forged = events.as_array().unwrap().iter()
.find(|e| e["action"] == "item-update")
.expect("forged item-update event present");
// Trailer claims ffff... but the verified signer is the owner → TAMPERED.
assert_eq!(forged["tampered"], serde_json::Value::Bool(true));
assert_eq!(forged["actor_id"].as_str(), Some(owner.as_str()));
}
// (c) concurrent rotate-key aborts with the exact spec error string.
#[test]
fn concurrent_rotate_key_aborts_with_spec_string() {
let (dev, vault, _owner) = setup_with_item();
let origin = TempDir::new().unwrap();
let v = vault.path();
let git = |args: &[&str]| Command::new("git").current_dir(v).args(args)
.env("XDG_CONFIG_HOME", &dev.xdg).output().unwrap();
// Make a bare origin and push, so a divergent upstream can be simulated.
assert!(Command::new("git").args(["init", "--bare", origin.path().to_str().unwrap()])
.output().unwrap().status.success());
assert!(git(&["remote", "add", "origin", origin.path().to_str().unwrap()]).status.success());
assert!(git(&["push", "-u", "origin", "HEAD"]).status.success());
// Diverge upstream: a second clone commits + pushes, writing to a SHARED file
// so that `git pull --rebase` will hit a merge conflict (add/add or edit/edit)
// and exit non-zero — which is how run_rotate_key detects a concurrent rotation.
let clone2 = TempDir::new().unwrap();
assert!(Command::new("git")
.args(["clone", origin.path().to_str().unwrap(), clone2.path().to_str().unwrap()])
.output().unwrap().status.success());
std::fs::write(clone2.path().join("conflict.txt"), "upstream-version").unwrap();
for a in [&["add", "conflict.txt"][..], &["-c", "user.email=u@u", "-c", "user.name=u", "commit", "-m", "upstream"][..], &["push", "origin", "HEAD:master"][..], &["push", "origin", "HEAD:main"][..]] {
let _ = Command::new("git").current_dir(clone2.path()).args(a).output();
}
// Local also writes conflict.txt with different content → add/add conflict on pull.
std::fs::write(v.join("conflict.txt"), "local-version").unwrap();
assert!(git(&["add", "conflict.txt"]).status.success());
assert!(git(&["-c", "commit.gpgsign=false", "commit", "-m", "local"]).status.success());
let out = dev.run(v, &["org", "rotate-key"]);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(!out.status.success(), "rotate-key should abort on a concurrent rotation");
assert!(
stderr.contains("Concurrent key rotation detected — pull and re-run org rotate-key."),
"missing spec error string: {stderr}"
);
}
// (d) remove-member → rotate-key → old clone cannot decrypt; remaining member can.
#[test]
fn removed_member_clone_cannot_decrypt_after_rotation() {
// Owner laptop sets up the org + a second member "bob".
let (owner_dev, vault, _owner) = setup_with_item();
let v = vault.path();
let bob = Dev::new("bob-laptop");
let bob_pub = bob.pubkey("bob-laptop");
// Owner adds Bob and grants him prod.
assert!(owner_dev.run(v, &["org", "add-member", "--key", &bob_pub, "--name", "Bob", "--role", "member"]).status.success());
let members = std::fs::read_to_string(v.join("members.json")).unwrap();
let mv: serde_json::Value = serde_json::from_str(&members).unwrap();
let bob_id = mv["members"].as_array().unwrap().iter()
.find(|m| m["display_name"] == "Bob").unwrap()["member_id"].as_str().unwrap().to_string();
assert!(owner_dev.run(v, &["org", "grant", &bob_id, "prod"]).status.success());
// Bob clones the vault dir (his device, his key blob is present).
// `cp -r /vault /dst/` places contents at `/dst/<vault_basename>/` — use that
// sub-path, not the TempDir root, as the vault for Bob's commands.
let bob_clone = TempDir::new().unwrap();
let vault_basename = v.file_name().unwrap();
let cp = Command::new("cp").args(["-r", v.to_str().unwrap(), bob_clone.path().to_str().unwrap()]).output().unwrap();
assert!(cp.status.success());
let bob_vault = bob_clone.path().join(vault_basename);
// Bob can read the item BEFORE removal.
let pre = bob.run(&bob_vault, &["org", "get", "GitHub", "--show"]);
assert!(String::from_utf8_lossy(&pre.stdout).contains("hunter2"), "bob should read pre-removal");
// Owner removes Bob and rotates the key in the live vault.
assert!(owner_dev.run(v, &["org", "remove-member", &bob_id]).status.success());
assert!(owner_dev.run(v, &["org", "rotate-key"]).status.success());
// Owner (remaining member) can still decrypt in the live vault.
let owner_get = owner_dev.run(v, &["org", "get", "GitHub", "--show"]);
assert!(String::from_utf8_lossy(&owner_get.stdout).contains("hunter2"), "owner must still read");
// Copy the rotated item + manifest into Bob's stale clone (simulating a
// pull) — his OLD key blob can no longer unwrap the rotated org key.
let _ = Command::new("cp").args(["-r",
v.join("items").to_str().unwrap(), bob_vault.to_str().unwrap()]).output();
let _ = std::fs::copy(v.join("manifest.enc"), bob_vault.join("manifest.enc"));
let post = bob.run(&bob_vault, &["org", "get", "GitHub", "--show"]);
assert!(!post.status.success() || !String::from_utf8_lossy(&post.stdout).contains("hunter2"),
"removed member must NOT decrypt post-rotation: {}", String::from_utf8_lossy(&post.stdout));
}