1use crate::error::MurkError;
14use crate::types;
15
16pub const RESERVED_GROUP_NAMES: &[&str] = &["everyone", "me", "all", "self", "mine", "shared"];
19
20pub fn is_reserved(name: &str) -> bool {
22 RESERVED_GROUP_NAMES.contains(&name)
23}
24
25pub fn validate_group_name(name: &str) -> Result<(), MurkError> {
27 if name.is_empty() {
28 return Err(MurkError::Group("group name cannot be empty".into()));
29 }
30 if name.len() > 64 {
31 return Err(MurkError::Group(
32 "group name too long (max 64 characters)".into(),
33 ));
34 }
35 if !name
36 .chars()
37 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
38 {
39 return Err(MurkError::Group(format!(
40 "invalid group name \"{name}\" — use letters, digits, dashes, underscores"
41 )));
42 }
43 if is_reserved(name) {
44 return Err(MurkError::Group(format!(
45 "\"{name}\" is a reserved name (it routes to the {name} tier, not a group)"
46 )));
47 }
48 Ok(())
49}
50
51pub fn create_group(
55 murk: &mut types::Murk,
56 name: &str,
57 creator_pubkey: &str,
58) -> Result<(), MurkError> {
59 validate_group_name(name)?;
60 if murk.groups.contains_key(name) {
61 return Err(MurkError::Group(format!("group already exists: {name}")));
62 }
63 murk.groups
64 .insert(name.into(), vec![creator_pubkey.to_string()]);
65 Ok(())
66}
67
68pub fn delete_group(
72 vault: &types::Vault,
73 murk: &mut types::Murk,
74 name: &str,
75) -> Result<(), MurkError> {
76 if !murk.groups.contains_key(name) {
77 return Err(MurkError::Group(format!("group not found: {name}")));
78 }
79 let assigned: Vec<&str> = vault
80 .secrets
81 .iter()
82 .filter(|(_, e)| e.grouped.contains_key(name))
83 .map(|(k, _)| k.as_str())
84 .collect();
85 if !assigned.is_empty() {
86 return Err(MurkError::Group(format!(
87 "group \"{name}\" still has {} secret(s) assigned ({}) — reassign or remove them first",
88 assigned.len(),
89 assigned.join(", ")
90 )));
91 }
92 murk.groups.remove(name);
93 Ok(())
94}
95
96pub fn resolve_member(
99 vault: &types::Vault,
100 murk: &types::Murk,
101 spec: &str,
102) -> Result<String, MurkError> {
103 if vault.recipients.iter().any(|pk| pk == spec) {
104 return Ok(spec.to_string());
105 }
106 let matched: Vec<&String> = murk
107 .recipients
108 .iter()
109 .filter(|(_, name)| name.as_str() == spec)
110 .map(|(pk, _)| pk)
111 .collect();
112 match matched.as_slice() {
113 [] => Err(MurkError::Group(format!(
114 "no recipient matches \"{spec}\" — authorize them first with `murk circle authorize`"
115 ))),
116 [pk] => Ok((*pk).clone()),
117 _ => Err(MurkError::Group(format!(
118 "ambiguous name \"{spec}\" matches {} recipients — use a pubkey",
119 matched.len()
120 ))),
121 }
122}
123
124pub fn add_member(
129 murk: &mut types::Murk,
130 name: &str,
131 member_pubkey: &str,
132 operator_pubkey: &str,
133) -> Result<bool, MurkError> {
134 let members = murk
135 .groups
136 .get_mut(name)
137 .ok_or_else(|| MurkError::Group(format!("group not found: {name}")))?;
138 if !members.iter().any(|pk| pk == operator_pubkey) {
139 return Err(MurkError::Group(format!(
140 "you must be a member of group \"{name}\" to modify it"
141 )));
142 }
143 if members.iter().any(|pk| pk == member_pubkey) {
144 return Ok(false);
145 }
146 members.push(member_pubkey.to_string());
147 Ok(true)
148}
149
150pub fn remove_member(
154 murk: &mut types::Murk,
155 name: &str,
156 member_pubkey: &str,
157 operator_pubkey: &str,
158) -> Result<bool, MurkError> {
159 let members = murk
160 .groups
161 .get_mut(name)
162 .ok_or_else(|| MurkError::Group(format!("group not found: {name}")))?;
163 if !members.iter().any(|pk| pk == operator_pubkey) {
164 return Err(MurkError::Group(format!(
165 "you must be a member of group \"{name}\" to modify it"
166 )));
167 }
168 if !members.iter().any(|pk| pk == member_pubkey) {
169 return Ok(false);
170 }
171 if members.len() == 1 {
172 return Err(MurkError::Group(format!(
173 "cannot remove the last member of group \"{name}\" — delete the group instead"
174 )));
175 }
176 members.retain(|pk| pk != member_pubkey);
177 Ok(true)
178}
179
180#[cfg(test)]
181mod tests {
182 use std::collections::BTreeMap;
183
184 use super::*;
185 use crate::testutil::{empty_murk, empty_vault};
186
187 #[test]
188 fn validate_rejects_reserved_and_invalid() {
189 assert!(validate_group_name("prod").is_ok());
190 assert!(validate_group_name("dev-team_1").is_ok());
191 assert!(validate_group_name("me").is_err());
192 assert!(validate_group_name("everyone").is_err());
193 assert!(validate_group_name("").is_err());
194 assert!(validate_group_name("has space").is_err());
195 assert!(validate_group_name(&"x".repeat(65)).is_err());
196 }
197
198 #[test]
199 fn create_seeds_creator_as_member() {
200 let mut murk = empty_murk();
201 create_group(&mut murk, "prod", "age1alice").unwrap();
202 assert_eq!(murk.groups["prod"], vec!["age1alice".to_string()]);
203 assert!(create_group(&mut murk, "prod", "age1alice").is_err());
205 }
206
207 #[test]
208 fn add_member_requires_operator_membership() {
209 let mut murk = empty_murk();
210 create_group(&mut murk, "prod", "age1alice").unwrap();
211 assert!(add_member(&mut murk, "prod", "age1bob", "age1carol").is_err());
213 assert!(add_member(&mut murk, "prod", "age1bob", "age1alice").unwrap());
215 assert!(!add_member(&mut murk, "prod", "age1bob", "age1alice").unwrap());
217 assert_eq!(murk.groups["prod"].len(), 2);
218 }
219
220 #[test]
221 fn remove_member_refuses_last() {
222 let mut murk = empty_murk();
223 create_group(&mut murk, "prod", "age1alice").unwrap();
224 add_member(&mut murk, "prod", "age1bob", "age1alice").unwrap();
225 assert!(remove_member(&mut murk, "prod", "age1bob", "age1alice").unwrap());
226 assert!(remove_member(&mut murk, "prod", "age1alice", "age1alice").is_err());
228 }
229
230 #[test]
231 fn delete_refuses_when_secrets_assigned() {
232 let mut vault = empty_vault();
233 let mut murk = empty_murk();
234 create_group(&mut murk, "prod", "age1alice").unwrap();
235
236 delete_group(&vault, &mut murk, "prod").unwrap();
238
239 create_group(&mut murk, "prod", "age1alice").unwrap();
241 vault.secrets.insert(
242 "K".into(),
243 types::SecretEntry {
244 grouped: BTreeMap::from([("prod".into(), "ct".into())]),
245 ..Default::default()
246 },
247 );
248 assert!(delete_group(&vault, &mut murk, "prod").is_err());
249 }
250
251 #[test]
252 fn resolve_member_by_name_and_pubkey() {
253 let vault = types::Vault {
254 recipients: vec!["age1alice".into()],
255 ..empty_vault()
256 };
257 let mut murk = empty_murk();
258 murk.recipients.insert("age1alice".into(), "alice".into());
259
260 assert_eq!(
261 resolve_member(&vault, &murk, "age1alice").unwrap(),
262 "age1alice"
263 );
264 assert_eq!(resolve_member(&vault, &murk, "alice").unwrap(), "age1alice");
265 assert!(resolve_member(&vault, &murk, "nobody").is_err());
266 }
267}