1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
//! The role roster: stable role id → a Role card (mutable display name, expected
//! host, description). One Markdown file per role (`roles/<id>.md`, YAML
//! frontmatter) — same conflict-free, Obsidian-editable format as messages;
//! the id (the filename) is permanent, the `display` is cosmetic and renameable.
//! Legacy `roles.toml` (a single shared-write file) is still read for migration.
use serde::Deserialize;
use std::collections::HashMap;
use std::path::Path;
#[derive(Debug, Clone, Default, Deserialize)]
pub struct Role {
#[serde(default)]
pub display: Option<String>,
/// Expected host (declaration) — where this role is meant to run.
#[serde(default)]
pub host: Option<String>,
/// One-line "what this agent is / does" — surfaced in `who`, matched by `whois`.
#[serde(default)]
pub desc: Option<String>,
/// Human-friendly nicknames/phrases the owner uses for this agent (e.g.
/// "iOS agent", "the mac mini one"). Matched by `whois`; self-maintained via
/// `describe`. See DESIGN.md.
#[serde(default)]
pub aliases: Vec<String>,
/// Self-declared lifecycle INTENT — `active` (default) | `dormant` | `retired`
///. Self-sovereign: honored only when the card edit is signature-verified
/// against the role's pinned key (`verify::card_trust`), so a peer can't set it. It's an
/// overlay on the presence heartbeat (which alone drives liveness/aging), never a liveness
/// claim itself.
#[serde(default)]
pub status: Option<String>,
/// The role's SSH public key (`ssh-ed25519 AAAA… comment`) — published so peers
/// can verify this role's signed commits. See DESIGN.md.
#[serde(default)]
pub pubkey: Option<String>,
}
/// The role's published SSH public key, if any.
pub fn pubkey<'a>(roster: &'a Roster, id: &str) -> Option<&'a str> {
roster.get(id).and_then(|r| r.pubkey.as_deref())
}
/// The SINGLE source of truth for "what key does this card publish", shared by the read/pin side
/// (`parse_role`, below) and the write-side 1:1 guard (`published_pubkey` in main.rs) so the two can
/// never disagree. Absent / null / "" → Ok(None) (no key). A non-empty string → Ok(Some). A present
/// but NON-string value (bool/number/list/mapping) → Err(type-name).
///
/// This is load-bearing: `roster::parse_role` used to deserialize straight into a typed
/// `Option<String>`, which silently STRINGIFIES a YAML bareword (`pubkey: true` → "true",
/// `pubkey: 12345` → "12345"), while the write guard decodes an untyped Value and refuses it. That
/// split let a hub writer poison a new peer's TOFU pin with a bogus literal like "true", permanently
/// mismatching the real role (identity-DoS — red-team). Routing BOTH sides through this one
/// classifier closes the divergence by construction.
pub(crate) fn classify_pubkey(map: &serde_yaml::Mapping) -> Result<Option<String>, String> {
match map.get("pubkey") {
None => Ok(None),
Some(v) if v.is_null() => Ok(None),
Some(v) => match v.as_str() {
Some(s) => {
let t = s.trim();
Ok((!t.is_empty()).then(|| t.to_string()))
}
None => Err(match v {
serde_yaml::Value::Sequence(_) => "list",
serde_yaml::Value::Mapping(_) => "mapping",
serde_yaml::Value::Number(_) => "number",
serde_yaml::Value::Bool(_) => "boolean",
_ => "non-string value",
}
.to_string()),
},
}
}
pub type Roster = HashMap<String, Role>;
/// Load `roles/<id>.md` (preferred) unioned over legacy `roles.toml`.
pub fn load(root: &Path) -> Roster {
let mut m = Roster::new();
// Legacy roles.toml (migration path).
if let Ok(txt) = std::fs::read_to_string(root.join("roles.toml")) {
if let Ok(val) = txt.parse::<toml::Value>() {
if let Some(roles) = val.get("roles").and_then(toml::Value::as_table) {
for (id, cfg) in roles {
let s = |k: &str| cfg.get(k).and_then(toml::Value::as_str).map(String::from);
m.insert(
id.clone(),
Role {
display: s("display"),
host: s("host"),
desc: s("desc"),
aliases: Vec::new(),
status: s("status"),
pubkey: None,
},
);
}
}
}
}
// roles/<id>.md wins over legacy.
let dir = root.join("roles");
if dir.is_dir() {
match std::fs::read_dir(&dir) {
Err(e) => eprintln!(
"confer: ⚠ cannot read {} ({e}) — every role's display/host/pubkey is MISSING this load \
(who/whois/verification will be blind).",
dir.display()
),
Ok(entries) => {
for e in entries.flatten() {
let p = e.path();
if p.extension().and_then(|s| s.to_str()) != Some("md") {
continue;
}
let Some(id) = p.file_stem().and_then(|s| s.to_str()) else {
continue;
};
if let Ok(txt) = std::fs::read_to_string(&p) {
match parse_role(&txt) {
Some(role) => {
m.insert(id.to_string(), role);
}
// A malformed card would otherwise make the role vanish
// from `who`/display resolution with no signal (S3).
None => eprintln!("confer: skipping malformed role card {}", p.display()),
}
}
}
}
}
}
m
}
/// Parse a role card's YAML frontmatter (body is ignored / freeform notes).
fn parse_role(text: &str) -> Option<Role> {
// Strip a leading UTF-8 BOM before the fence-sniff — keep this identical to `parse_card` in
// main.rs, so the read side and the write-side key guard can't disagree about whether a BOM'd
// card has frontmatter (a divergence there was an identity-hijack vector — red-team).
let text = text.strip_prefix('\u{FEFF}').unwrap_or(text);
let mut lines = text.lines();
if lines.next().map(str::trim_end) != Some("---") {
return None;
}
let mut yaml = String::new();
for line in lines {
if line.trim_end() == "---" {
break;
}
yaml.push_str(line);
yaml.push('\n');
}
let mut role: Role = serde_yaml::from_str(&yaml).ok()?;
// Re-derive `pubkey` through the SHARED classifier rather than trusting the typed
// `Option<String>` deserialize, which stringifies a YAML bareword (`pubkey: true` → "true") that
// the write-side guard refuses — the split let an attacker poison a peer's TOFU pin (red-team).
// On the read side a non-string / absent pubkey degrades to None (unverifiable), never a bogus
// literal, so it can't be pinned.
if let Ok(m) = serde_yaml::from_str::<serde_yaml::Mapping>(&yaml) {
role.pubkey = classify_pubkey(&m).ok().flatten();
} else {
role.pubkey = None;
}
Some(role)
}
/// Resolve a role id to its display name (falls back to the id).
pub fn display<'a>(roster: &'a Roster, id: &'a str) -> &'a str {
roster
.get(id)
.and_then(|r| r.display.as_deref())
.unwrap_or(id)
}
/// The role's declared (expected) host, if any.
pub fn host<'a>(roster: &'a Roster, id: &str) -> Option<&'a str> {
roster.get(id).and_then(|r| r.host.as_deref())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_role_never_pins_a_nonstring_pubkey() {
// Read-side classification MUST match the write guard: a YAML bareword (`pubkey: true`) or
// any non-string value degrades to None (unverifiable), never a bogus literal like "true"
// that would poison a peer's TOFU pin (red-team round 5).
assert_eq!(parse_role("---\ndisplay: v\npubkey: true\n---\n").unwrap().pubkey, None);
assert_eq!(parse_role("---\npubkey: 12345\n---\n").unwrap().pubkey, None);
assert_eq!(parse_role("---\npubkey: null\n---\n").unwrap().pubkey, None);
// In YAML 1.2 (serde_yaml 0.9) `yes` is a STRING, not a bool — so both sides agree on
// Some("yes") (no divergence; it just can't satisfy a real signature). Documented here so the
// distinction from `true`/`false`/numbers is explicit.
assert_eq!(
parse_role("---\npubkey: yes\n---\n").unwrap().pubkey.as_deref(),
Some("yes")
);
// A real key string is read normally, and a BOM before the fence doesn't hide it.
assert_eq!(
parse_role("---\npubkey: ssh-ed25519 AAAA x\n---\n").unwrap().pubkey.as_deref(),
Some("ssh-ed25519 AAAA x")
);
assert_eq!(
parse_role("\u{FEFF}---\npubkey: ssh-ed25519 AAAA x\n---\n").unwrap().pubkey.as_deref(),
Some("ssh-ed25519 AAAA x")
);
}
}