1use std::collections::BTreeMap;
20use std::path::Path;
21
22use joy_crypt::identity::{Keypair, PublicKey};
23
24use crate::crypt::{self, ZoneKey};
25use crate::error::JoyError;
26use crate::member_id::{email_match, opaque_member_id};
27use crate::members_file::{self, MemberInfo, MembersFile, MEMBERS_ZONE};
28use crate::model::project::{Member, PrivacyMode};
29use crate::model::Project;
30use crate::store;
31
32fn is_human_key(key: &str) -> bool {
36 !key.starts_with("ai:")
37}
38
39pub fn member_key_for_email(project: &Project, email: &str) -> Option<String> {
44 if project.privacy_mode() != PrivacyMode::Anonymous {
45 return project
46 .members
47 .contains_key(email)
48 .then(|| email.to_string());
49 }
50 for (id, member) in &project.members {
51 if let (Some(verifier), Some(nonce)) = (&member.email_match, &member.kdf_nonce) {
52 if email_match(email, nonce).ok().as_deref() == Some(verifier.as_str()) {
53 return Some(id.clone());
54 }
55 }
56 }
57 None
58}
59
60pub fn email_for(
69 project: &Project,
70 member: &str,
71 members: Option<&crate::members_file::MembersFile>,
72) -> Option<String> {
73 if project.privacy_mode() != PrivacyMode::Anonymous {
74 return project
75 .members
76 .contains_key(member)
77 .then(|| member.to_string());
78 }
79 members.and_then(|m| m.email_for(member).map(str::to_string))
80}
81
82fn io_err(ctx: &str, e: std::io::Error) -> JoyError {
83 JoyError::Other(format!("{ctx}: {e}"))
84}
85
86pub fn erase_member(
94 root: &Path,
95 project: &Project,
96 operator_seed: &[u8; 32],
97 target_id: &str,
98) -> Result<bool, JoyError> {
99 if project.privacy_mode() != PrivacyMode::Anonymous {
100 return Err(JoyError::Other(
101 "erasure applies only to anonymous projects".into(),
102 ));
103 }
104 let operator_vk = Keypair::from_seed(operator_seed).public_key().to_hex();
105 let wrap = project
106 .members
107 .values()
108 .find(|m| m.verify_key.as_deref() == Some(operator_vk.as_str()))
109 .and_then(|m| m.members_wrap.clone())
110 .ok_or_else(|| JoyError::Other("operator has no members.yaml access wrap".into()))?;
111 let zone_key = crypt::unwrap_for_member(&wrap, MEMBERS_ZONE, operator_seed)?;
112 let mut mf = members_file::read(root, &zone_key)?;
113 let removed = mf.members.remove(target_id).is_some();
114 if removed {
115 members_file::write(root, &zone_key, &mf)?;
116 }
117 Ok(removed)
118}
119
120fn rewrite_file(path: &Path, replacements: &[(String, String)]) -> Result<(), JoyError> {
122 if !path.exists() {
123 return Ok(());
124 }
125 let mut content = std::fs::read_to_string(path).map_err(|e| io_err("read", e))?;
126 let mut changed = false;
127 for (from, to) in replacements {
128 if !from.is_empty() && content.contains(from.as_str()) {
129 content = content.replace(from.as_str(), to);
130 changed = true;
131 }
132 }
133 if changed {
134 std::fs::write(path, content).map_err(|e| io_err("write", e))?;
135 }
136 Ok(())
137}
138
139fn rewrite_dir(dir: &Path, ext: &str, replacements: &[(String, String)]) -> Result<(), JoyError> {
141 if !dir.exists() {
142 return Ok(());
143 }
144 for entry in std::fs::read_dir(dir).map_err(|e| io_err("read_dir", e))? {
145 let path = entry.map_err(|e| io_err("read_dir entry", e))?.path();
146 if path.extension().and_then(|e| e.to_str()) == Some(ext) {
147 rewrite_file(&path, replacements)?;
148 }
149 }
150 Ok(())
151}
152
153fn rewrite_working_tree(root: &Path, replacements: &[(String, String)]) -> Result<(), JoyError> {
158 let joy = store::joy_dir(root);
159 rewrite_file(&joy.join(store::PROJECT_FILE), replacements)?;
160 rewrite_dir(&joy.join(store::ITEMS_DIR), "yaml", replacements)?;
161 rewrite_dir(&joy.join(store::LOG_DIR), "log", replacements)?;
162 Ok(())
163}
164
165fn prune_yaml_key(path: &Path, key: &str) -> Result<(), JoyError> {
170 use serde_yaml_ng::Value;
171 let raw = std::fs::read_to_string(path).map_err(|e| io_err("read", e))?;
172 let mut value: Value = serde_yaml_ng::from_str(&raw)?;
173 if let Some(map) = value.as_mapping_mut() {
174 map.remove(Value::String(key.to_string()));
175 }
176 let yaml = serde_yaml_ng::to_string(&value)?;
177 std::fs::write(path, yaml).map_err(|e| io_err("write", e))?;
178 Ok(())
179}
180
181pub fn switch_to_anonymous(
187 root: &Path,
188 project: &mut Project,
189 operator_seed: &[u8; 32],
190) -> Result<Vec<(String, String)>, JoyError> {
191 if project.privacy_mode() == PrivacyMode::Anonymous {
192 return Err(JoyError::Other("project is already anonymous".into()));
193 }
194
195 let operator_pk = Keypair::from_seed(operator_seed).public_key();
196 let zone_key = ZoneKey::generate();
197
198 let mut renamed: Vec<(String, String)> = Vec::new();
199 let mut new_members: BTreeMap<String, Member> = BTreeMap::new();
200 let mut mf = MembersFile::default();
201
202 for (key, mut member) in std::mem::take(&mut project.members) {
203 if !is_human_key(&key) {
204 new_members.insert(key, member);
206 continue;
207 }
208 let email = key;
209 let verify_key = member.verify_key.clone().ok_or_else(|| {
210 JoyError::Other(format!(
211 "member {email} has no verify_key; run joy auth init first"
212 ))
213 })?;
214 let kdf_nonce = member
215 .kdf_nonce
216 .clone()
217 .ok_or_else(|| JoyError::Other(format!("member {email} has no kdf_nonce")))?;
218
219 let id = opaque_member_id(&verify_key)
220 .map_err(|e| JoyError::Other(format!("bad verify_key for {email}: {e}")))?;
221 let verifier = email_match(&email, &kdf_nonce)
222 .map_err(|e| JoyError::Other(format!("bad kdf_nonce for {email}: {e}")))?;
223
224 let recipient_pk = PublicKey::from_hex(&verify_key)?;
225 let wrap = crypt::wrap_for_member(
226 &zone_key,
227 MEMBERS_ZONE,
228 operator_seed,
229 &operator_pk,
230 &recipient_pk,
231 );
232
233 member.email_match = Some(verifier);
234 member.members_wrap = Some(wrap);
235
236 mf.members.insert(
237 id.clone(),
238 MemberInfo {
239 email: email.clone(),
240 name: None,
241 },
242 );
243 renamed.push((email, id.clone()));
244 new_members.insert(id, member);
245 }
246
247 project.members = new_members;
248 project.privacy = Some(PrivacyMode::Anonymous);
249
250 let project_path = store::joy_dir(root).join(store::PROJECT_FILE);
253 store::write_yaml_preserve(&project_path, project)?;
254 members_file::write(root, &zone_key, &mf)?;
255 rewrite_working_tree(root, &renamed)?;
256
257 Ok(renamed)
258}
259
260pub fn switch_to_open(
265 root: &Path,
266 project: &mut Project,
267 operator_seed: &[u8; 32],
268) -> Result<Vec<(String, String)>, JoyError> {
269 if project.privacy_mode() != PrivacyMode::Anonymous {
270 return Err(JoyError::Other("project is not anonymous".into()));
271 }
272
273 let operator_vk = Keypair::from_seed(operator_seed).public_key().to_hex();
276 let wrap = project
277 .members
278 .values()
279 .find(|m| m.verify_key.as_deref() == Some(operator_vk.as_str()))
280 .and_then(|m| m.members_wrap.clone())
281 .ok_or_else(|| JoyError::Other("operator has no members.yaml access wrap".into()))?;
282 let zone_key = crypt::unwrap_for_member(&wrap, MEMBERS_ZONE, operator_seed)?;
283 let mf = members_file::read(root, &zone_key)?;
284
285 let mut renamed: Vec<(String, String)> = Vec::new();
286 let mut new_members: BTreeMap<String, Member> = BTreeMap::new();
287
288 for (key, mut member) in std::mem::take(&mut project.members) {
289 if !is_human_key(&key) && !mf.members.contains_key(&key) {
290 new_members.insert(key, member);
291 continue;
292 }
293 match mf.email_for(&key) {
294 Some(email) => {
295 member.email_match = None;
296 member.members_wrap = None;
297 renamed.push((key.clone(), email.to_string()));
298 new_members.insert(email.to_string(), member);
299 }
300 None => {
301 new_members.insert(key, member);
303 }
304 }
305 }
306
307 project.members = new_members;
308 project.privacy = None;
309
310 let project_path = store::joy_dir(root).join(store::PROJECT_FILE);
311 store::write_yaml_preserve(&project_path, project)?;
312 prune_yaml_key(&project_path, "privacy")?;
313 let mp = members_file::members_path(root);
315 if mp.exists() {
316 std::fs::remove_file(&mp).map_err(|e| io_err("remove members.yaml", e))?;
317 }
318 rewrite_working_tree(root, &renamed)?;
319
320 Ok(renamed)
321}
322
323#[cfg(test)]
324mod tests {
325 use super::*;
326 use crate::model::project::MemberCapabilities;
327
328 const EMAIL: &str = "test@example.com";
329 const NONCE: &str = "8c1f00000000000000000000000000000000000000000000000000000000e4ab";
330
331 fn setup(root: &Path, seed: &[u8; 32]) -> Project {
332 let joy = store::joy_dir(root);
333 std::fs::create_dir_all(joy.join(store::ITEMS_DIR)).unwrap();
334 std::fs::create_dir_all(joy.join(store::LOG_DIR)).unwrap();
335
336 let vk = Keypair::from_seed(seed).public_key().to_hex();
337 let mut member = Member::new(MemberCapabilities::All);
338 member.verify_key = Some(vk);
339 member.kdf_nonce = Some(NONCE.to_string());
340
341 let mut project = Project::new("Test".into(), Some("T".into()));
342 project.members.insert(EMAIL.to_string(), member);
343 store::write_yaml_preserve(&joy.join(store::PROJECT_FILE), &project).unwrap();
344
345 std::fs::write(
347 joy.join(store::ITEMS_DIR).join("T-0001-x.yaml"),
348 format!("id: T-0001\ntitle: x\nassignees:\n- member: {EMAIL}\ncreated_by: {EMAIL}\n"),
349 )
350 .unwrap();
351 std::fs::write(
352 joy.join(store::LOG_DIR).join("2026-06-11.log"),
353 format!("2026-06-11T09:00:00Z T-0001 item.created [{EMAIL}]\n"),
354 )
355 .unwrap();
356
357 project
358 }
359
360 fn no_email_anywhere(root: &Path) -> bool {
361 let joy = store::joy_dir(root);
362 for sub in [store::PROJECT_FILE] {
363 if std::fs::read_to_string(joy.join(sub))
364 .unwrap()
365 .contains(EMAIL)
366 {
367 return false;
368 }
369 }
370 for dir in [store::ITEMS_DIR, store::LOG_DIR] {
371 for entry in std::fs::read_dir(joy.join(dir)).unwrap() {
372 let p = entry.unwrap().path();
373 if std::fs::read(&p)
374 .unwrap()
375 .windows(EMAIL.len())
376 .any(|w| w == EMAIL.as_bytes())
377 {
378 return false;
379 }
380 }
381 }
382 true
383 }
384
385 #[test]
386 fn switch_round_trip_scrubs_then_restores_emails() {
387 let dir = tempfile::tempdir().unwrap();
388 let root = dir.path();
389 let seed = [7u8; 32];
390 let mut project = setup(root, &seed);
391
392 assert!(!no_email_anywhere(root));
394
395 let renamed = switch_to_anonymous(root, &mut project, &seed).unwrap();
397 assert_eq!(renamed.len(), 1);
398 let id = renamed[0].1.clone();
399 assert!(id.starts_with("m-"));
400 assert!(
401 no_email_anywhere(root),
402 "no e-mail must remain after switch"
403 );
404 assert!(members_file::exists(root));
405 let raw = std::fs::read(members_file::members_path(root)).unwrap();
406 assert!(crypt::looks_like_blob(&raw));
407 let pj: Project =
409 store::read_yaml(&store::joy_dir(root).join(store::PROJECT_FILE)).unwrap();
410 assert!(pj.members.contains_key(&id));
411 assert_eq!(pj.privacy, Some(PrivacyMode::Anonymous));
412 assert!(pj.members[&id].email_match.is_some());
413
414 switch_to_open(root, &mut project, &seed).unwrap();
416 assert!(!no_email_anywhere(root), "e-mail must be restored");
417 assert!(!members_file::exists(root));
418 let pj2: Project =
419 store::read_yaml(&store::joy_dir(root).join(store::PROJECT_FILE)).unwrap();
420 assert!(pj2.members.contains_key(EMAIL));
421 assert_eq!(pj2.privacy, None);
422 assert!(pj2.members[EMAIL].email_match.is_none());
423 }
424}