Skip to main content

joy_core/
members_file.rs

1// Copyright (c) 2026 Joydev GmbH (joydev.com)
2// SPDX-License-Identifier: MIT
3
4//! Encrypted `members.yaml` (ADR-042 anonymous mode).
5//!
6//! Maps each opaque member id (`m-<short>`) to its human-readable resolution
7//! data: e-mail and optional display name. On disk the file exists only as a
8//! `JOYCRYPT` blob, encrypted under a dedicated Crypt zone whose key is wrapped
9//! per member against their `verify_key` (the same machinery as any Crypt zone,
10//! ADR-038 / ADR-039). Plaintext member e-mail therefore never hits disk.
11//!
12//! `name` is optional and not populated yet (the first cut sources nothing into
13//! it); display degrades to the e-mail when it is absent. Adding a name source
14//! later is purely additive: populate the field, no format change.
15
16use 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
25/// Reserved Crypt zone for the members file. The double underscores keep it out
26/// of the user-facing `joy crypt` zone namespace.
27pub const MEMBERS_ZONE: &str = "__members__";
28/// On-disk filename, under `.joy/`.
29pub const MEMBERS_FILE: &str = "members.yaml";
30
31/// Decrypted contents of `members.yaml`: opaque id -> resolution data.
32#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
33pub struct MembersFile {
34    #[serde(default)]
35    pub members: BTreeMap<String, MemberInfo>,
36}
37
38/// Per-member human-readable data. `name` is optional; display falls back to
39/// the e-mail when it is absent.
40#[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    /// The e-mail for an opaque id, if present.
49    pub fn email_for(&self, id: &str) -> Option<&str> {
50        self.members.get(id).map(|m| m.email.as_str())
51    }
52
53    /// Display string for an opaque id: name if set, else e-mail. `None` when
54    /// the id is not in the file (the caller then keeps showing nothing rather
55    /// than a raw id, or requests authentication).
56    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
63/// Path to the (encrypted) `members.yaml` under `.joy/`.
64pub fn members_path(root: &Path) -> PathBuf {
65    store::joy_dir(root).join(MEMBERS_FILE)
66}
67
68/// Whether an (encrypted) `members.yaml` exists on disk.
69pub fn exists(root: &Path) -> bool {
70    members_path(root).exists()
71}
72
73/// Decrypt and parse `members.yaml` using the members-zone key.
74pub 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
93/// Serialize, encrypt, and write `members.yaml` with the members-zone key.
94pub 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}