use crate::error::MurkError;
use crate::types;
pub const RESERVED_GROUP_NAMES: &[&str] = &["everyone", "me", "all", "self", "mine", "shared"];
pub fn is_reserved(name: &str) -> bool {
RESERVED_GROUP_NAMES.contains(&name)
}
pub fn validate_group_name(name: &str) -> Result<(), MurkError> {
if name.is_empty() {
return Err(MurkError::Group("group name cannot be empty".into()));
}
if name.len() > 64 {
return Err(MurkError::Group(
"group name too long (max 64 characters)".into(),
));
}
if !name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
{
return Err(MurkError::Group(format!(
"invalid group name \"{name}\" — use letters, digits, dashes, underscores"
)));
}
if is_reserved(name) {
return Err(MurkError::Group(format!(
"\"{name}\" is a reserved name (it routes to the {name} tier, not a group)"
)));
}
Ok(())
}
pub fn create_group(
murk: &mut types::Murk,
name: &str,
creator_pubkey: &str,
) -> Result<(), MurkError> {
validate_group_name(name)?;
if murk.groups.contains_key(name) {
return Err(MurkError::Group(format!("group already exists: {name}")));
}
murk.groups
.insert(name.into(), vec![creator_pubkey.to_string()]);
Ok(())
}
pub fn delete_group(
vault: &types::Vault,
murk: &mut types::Murk,
name: &str,
) -> Result<(), MurkError> {
if !murk.groups.contains_key(name) {
return Err(MurkError::Group(format!("group not found: {name}")));
}
let assigned: Vec<&str> = vault
.secrets
.iter()
.filter(|(_, e)| e.grouped.contains_key(name))
.map(|(k, _)| k.as_str())
.collect();
if !assigned.is_empty() {
return Err(MurkError::Group(format!(
"group \"{name}\" still has {} secret(s) assigned ({}) — reassign or remove them first",
assigned.len(),
assigned.join(", ")
)));
}
murk.groups.remove(name);
Ok(())
}
pub fn resolve_member(
vault: &types::Vault,
murk: &types::Murk,
spec: &str,
) -> Result<String, MurkError> {
if vault.recipients.iter().any(|pk| pk == spec) {
return Ok(spec.to_string());
}
let matched: Vec<&String> = murk
.recipients
.iter()
.filter(|(_, name)| name.as_str() == spec)
.map(|(pk, _)| pk)
.collect();
match matched.as_slice() {
[] => Err(MurkError::Group(format!(
"no recipient matches \"{spec}\" — authorize them first with `murk circle authorize`"
))),
[pk] => Ok((*pk).clone()),
_ => Err(MurkError::Group(format!(
"ambiguous name \"{spec}\" matches {} recipients — use a pubkey",
matched.len()
))),
}
}
pub fn add_member(
murk: &mut types::Murk,
name: &str,
member_pubkey: &str,
operator_pubkey: &str,
) -> Result<bool, MurkError> {
let members = murk
.groups
.get_mut(name)
.ok_or_else(|| MurkError::Group(format!("group not found: {name}")))?;
if !members.iter().any(|pk| pk == operator_pubkey) {
return Err(MurkError::Group(format!(
"you must be a member of group \"{name}\" to modify it"
)));
}
if members.iter().any(|pk| pk == member_pubkey) {
return Ok(false);
}
members.push(member_pubkey.to_string());
Ok(true)
}
pub fn remove_member(
murk: &mut types::Murk,
name: &str,
member_pubkey: &str,
operator_pubkey: &str,
) -> Result<bool, MurkError> {
let members = murk
.groups
.get_mut(name)
.ok_or_else(|| MurkError::Group(format!("group not found: {name}")))?;
if !members.iter().any(|pk| pk == operator_pubkey) {
return Err(MurkError::Group(format!(
"you must be a member of group \"{name}\" to modify it"
)));
}
if !members.iter().any(|pk| pk == member_pubkey) {
return Ok(false);
}
if members.len() == 1 {
return Err(MurkError::Group(format!(
"cannot remove the last member of group \"{name}\" — delete the group instead"
)));
}
members.retain(|pk| pk != member_pubkey);
Ok(true)
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use super::*;
use crate::testutil::{empty_murk, empty_vault};
#[test]
fn validate_rejects_reserved_and_invalid() {
assert!(validate_group_name("prod").is_ok());
assert!(validate_group_name("dev-team_1").is_ok());
assert!(validate_group_name("me").is_err());
assert!(validate_group_name("everyone").is_err());
assert!(validate_group_name("").is_err());
assert!(validate_group_name("has space").is_err());
assert!(validate_group_name(&"x".repeat(65)).is_err());
}
#[test]
fn create_seeds_creator_as_member() {
let mut murk = empty_murk();
create_group(&mut murk, "prod", "age1alice").unwrap();
assert_eq!(murk.groups["prod"], vec!["age1alice".to_string()]);
assert!(create_group(&mut murk, "prod", "age1alice").is_err());
}
#[test]
fn add_member_requires_operator_membership() {
let mut murk = empty_murk();
create_group(&mut murk, "prod", "age1alice").unwrap();
assert!(add_member(&mut murk, "prod", "age1bob", "age1carol").is_err());
assert!(add_member(&mut murk, "prod", "age1bob", "age1alice").unwrap());
assert!(!add_member(&mut murk, "prod", "age1bob", "age1alice").unwrap());
assert_eq!(murk.groups["prod"].len(), 2);
}
#[test]
fn remove_member_refuses_last() {
let mut murk = empty_murk();
create_group(&mut murk, "prod", "age1alice").unwrap();
add_member(&mut murk, "prod", "age1bob", "age1alice").unwrap();
assert!(remove_member(&mut murk, "prod", "age1bob", "age1alice").unwrap());
assert!(remove_member(&mut murk, "prod", "age1alice", "age1alice").is_err());
}
#[test]
fn delete_refuses_when_secrets_assigned() {
let mut vault = empty_vault();
let mut murk = empty_murk();
create_group(&mut murk, "prod", "age1alice").unwrap();
delete_group(&vault, &mut murk, "prod").unwrap();
create_group(&mut murk, "prod", "age1alice").unwrap();
vault.secrets.insert(
"K".into(),
types::SecretEntry {
grouped: BTreeMap::from([("prod".into(), "ct".into())]),
..Default::default()
},
);
assert!(delete_group(&vault, &mut murk, "prod").is_err());
}
#[test]
fn resolve_member_by_name_and_pubkey() {
let vault = types::Vault {
recipients: vec!["age1alice".into()],
..empty_vault()
};
let mut murk = empty_murk();
murk.recipients.insert("age1alice".into(), "alice".into());
assert_eq!(
resolve_member(&vault, &murk, "age1alice").unwrap(),
"age1alice"
);
assert_eq!(resolve_member(&vault, &murk, "alice").unwrap(), "age1alice");
assert!(resolve_member(&vault, &murk, "nobody").is_err());
}
}