1use std::collections::BTreeMap;
17use std::path::{Path, PathBuf};
18
19use serde::{Deserialize, Serialize};
20
21use crate::crypt::{self, ZoneKey};
22use crate::error::JoyError;
23use crate::store;
24
25pub const MEMBERS_ZONE: &str = "__members__";
28pub const MEMBERS_FILE: &str = "members.yaml";
30
31#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
33pub struct MembersFile {
34 #[serde(default)]
35 pub members: BTreeMap<String, MemberInfo>,
36}
37
38#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
41pub struct MemberInfo {
42 pub email: String,
43 #[serde(default, skip_serializing_if = "Option::is_none")]
44 pub name: Option<String>,
45}
46
47impl MembersFile {
48 pub fn email_for(&self, id: &str) -> Option<&str> {
50 self.members.get(id).map(|m| m.email.as_str())
51 }
52
53 pub fn display_for(&self, id: &str) -> Option<String> {
57 self.members
58 .get(id)
59 .map(|m| m.name.clone().unwrap_or_else(|| m.email.clone()))
60 }
61}
62
63pub fn members_path(root: &Path) -> PathBuf {
65 store::joy_dir(root).join(MEMBERS_FILE)
66}
67
68pub fn exists(root: &Path) -> bool {
70 members_path(root).exists()
71}
72
73pub fn read(root: &Path, zone_key: &ZoneKey) -> Result<MembersFile, JoyError> {
75 let blob = std::fs::read(members_path(root))
76 .map_err(|e| JoyError::Other(format!("read members.yaml: {e}")))?;
77 let (_zone, plain) = crypt::decrypt_blob(
78 |z| {
79 if z == MEMBERS_ZONE {
80 Some(ZoneKey::from_bytes(*zone_key.as_bytes()))
81 } else {
82 None
83 }
84 },
85 &blob,
86 )?;
87 let text = String::from_utf8(plain)
88 .map_err(|_| JoyError::Other("members.yaml is not valid UTF-8".into()))?;
89 let mf: MembersFile = serde_yaml_ng::from_str(&text)?;
90 Ok(mf)
91}
92
93pub fn write(root: &Path, zone_key: &ZoneKey, mf: &MembersFile) -> Result<(), JoyError> {
95 let yaml = serde_yaml_ng::to_string(mf)?;
96 let blob = crypt::encrypt_blob(MEMBERS_ZONE, zone_key, yaml.as_bytes());
97 std::fs::write(members_path(root), blob)
98 .map_err(|e| JoyError::Other(format!("write members.yaml: {e}")))?;
99 Ok(())
100}
101
102#[cfg(test)]
103mod tests {
104 use super::*;
105
106 fn info(email: &str, name: Option<&str>) -> MemberInfo {
107 MemberInfo {
108 email: email.to_string(),
109 name: name.map(str::to_string),
110 }
111 }
112
113 #[test]
114 fn roundtrip_is_encrypted_on_disk() {
115 let dir = tempfile::tempdir().unwrap();
116 std::fs::create_dir_all(store::joy_dir(dir.path())).unwrap();
117 let zk = ZoneKey::generate();
118
119 let mut mf = MembersFile::default();
120 mf.members
121 .insert("m-aaaa".into(), info("horst@joydev.com", None));
122 mf.members.insert(
123 "m-bbbb".into(),
124 info("geordi@example.org", Some("Geordi LaForge")),
125 );
126 write(dir.path(), &zk, &mf).unwrap();
127
128 let raw = std::fs::read(members_path(dir.path())).unwrap();
129 assert!(
130 crypt::looks_like_blob(&raw),
131 "members.yaml must be a JOYCRYPT blob"
132 );
133 assert!(
134 !String::from_utf8_lossy(&raw).contains("horst@joydev.com"),
135 "e-mail must not appear in the on-disk blob"
136 );
137
138 let back = read(dir.path(), &zk).unwrap();
139 assert_eq!(back, mf);
140 }
141
142 #[test]
143 fn wrong_key_fails_to_read() {
144 let dir = tempfile::tempdir().unwrap();
145 std::fs::create_dir_all(store::joy_dir(dir.path())).unwrap();
146 let zk = ZoneKey::generate();
147 write(dir.path(), &zk, &MembersFile::default()).unwrap();
148 let other = ZoneKey::generate();
149 assert!(read(dir.path(), &other).is_err());
150 }
151
152 #[test]
153 fn display_prefers_name_then_email() {
154 let mut mf = MembersFile::default();
155 mf.members
156 .insert("m-1".into(), info("a@x.com", Some("Alice")));
157 mf.members.insert("m-2".into(), info("b@x.com", None));
158 assert_eq!(mf.display_for("m-1").as_deref(), Some("Alice"));
159 assert_eq!(mf.display_for("m-2").as_deref(), Some("b@x.com"));
160 assert_eq!(mf.email_for("m-1"), Some("a@x.com"));
161 assert_eq!(mf.display_for("m-unknown"), None);
162 }
163}