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
//! Cached authorized-node roster: offline membership check, persisted to
//! `~/.zakuro/roster.json` so brokers can verify peers without a live
//! dashboard round-trip.
use serde::{Deserialize, Serialize};
use super::node_identity::fingerprint_of_pubkey_b64;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct RosterFile {
entries: Vec<(String, bool)>,
fetched_at: u64,
}
/// Cached snapshot of the dashboard's authorized-node roster.
#[derive(Debug, Clone, Default)]
pub struct RosterCache {
entries: Vec<(String, bool)>,
fetched_at: u64,
}
impl RosterCache {
/// Build a cache directly from `(pubkey_b64, revoked)` entries.
pub fn from_entries(entries: Vec<(String, bool)>) -> RosterCache {
RosterCache {
entries,
fetched_at: super::node_identity::now_secs(),
}
}
/// True iff `pubkey_b64` is present in the roster and not revoked.
pub fn is_authorized(&self, pubkey_b64: &str) -> bool {
self.entries
.iter()
.any(|(pk, revoked)| pk == pubkey_b64 && !revoked)
}
/// True iff any non-revoked roster pubkey's fingerprint equals `fp`.
pub fn fingerprint_authorized(&self, fp: &str) -> bool {
self.entries.iter().any(|(pk, revoked)| {
!revoked
&& fingerprint_of_pubkey_b64(pk)
.map(|f| f == fp)
.unwrap_or(false)
})
}
/// Refresh from the dashboard; on success, overwrite entries and persist
/// to `~/.zakuro/roster.json` (mode 0600, best-effort). On failure, keep
/// the existing entries.
pub fn refresh(&mut self, api_url: &str, api_key: &str) {
match super::node_sync::fetch_roster(api_url, api_key) {
Ok(entries) => {
self.entries = entries;
self.fetched_at = super::node_identity::now_secs();
self.persist();
}
Err(_) => {
// keep existing entries
}
}
}
/// Entries currently held in the cache (`pubkey_b64`, `revoked`).
pub fn entries(&self) -> Vec<(String, bool)> {
self.entries.clone()
}
/// Best-effort: write this cache to `~/.zakuro/roster.json` (mode 0600).
/// Public so callers that fetch the live dashboard roster through a path
/// other than [`refresh`] (e.g. the broker's own periodic tick loop) can
/// still persist it for offline restarts.
pub fn persist(&self) {
let Some(dir) = crate::credentials::dir() else {
return;
};
let _ = std::fs::create_dir_all(&dir);
let path = dir.join("roster.json");
let file = RosterFile {
entries: self.entries.clone(),
fetched_at: self.fetched_at,
};
if let Ok(json) = serde_json::to_string(&file) {
if std::fs::write(&path, json).is_ok() {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
}
}
}
}
/// Load from `~/.zakuro/roster.json`; empty cache if absent or unparsable.
pub fn load() -> RosterCache {
let Some(dir) = crate::credentials::dir() else {
return RosterCache::default();
};
let path = dir.join("roster.json");
let Ok(text) = std::fs::read_to_string(&path) else {
return RosterCache::default();
};
match serde_json::from_str::<RosterFile>(&text) {
Ok(file) => RosterCache {
entries: file.entries,
fetched_at: file.fetched_at,
},
Err(_) => RosterCache::default(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::broker::node_identity::NodeKey;
#[test]
fn authorized_only_when_present_and_not_revoked() {
let rc =
RosterCache::from_entries(vec![("PUBKEY_A".into(), false), ("PUBKEY_B".into(), true)]);
assert!(rc.is_authorized("PUBKEY_A"));
assert!(!rc.is_authorized("PUBKEY_B")); // revoked
assert!(!rc.is_authorized("PUBKEY_C")); // absent
}
/// Finding 2 (wiring): `RosterCache::persist`/`load` round-trip through
/// `~/.zakuro/roster.json` via `credentials::dir()`, proving the
/// dashboard-optional offline path actually works — a broker that
/// persisted a roster on a prior successful dashboard fetch can load it
/// back (e.g. at startup, or as the discovery round's fallback when the
/// live in-memory roster is empty) without a live dashboard round-trip.
#[test]
fn persist_then_load_roundtrips_roster_entries() {
// Isolate from any real ~/.zakuro on the test machine: `credentials::dir()`
// resolves off `HOME`, so point HOME at a scratch dir for the duration of
// this test (tests run single-threaded here — see the constraint in the
// final-fix-report — so this process-global env var is safe to mutate).
// That assumption does not hold on CI's default thread pool, hence the
// lock: it is what actually makes the mutation below safe.
let _env = crate::credentials::HOME_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let dir = std::env::temp_dir().join(format!(
"zc2-roster-persist-test-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let _ = std::fs::create_dir_all(&dir);
let prev = std::env::var("HOME").ok();
// ZAKURO_HOME outranks HOME in `credentials::dir()`, so a developer
// (or CI image) with it exported would otherwise send this test's
// writes somewhere real while HOME redirection looked effective.
let prev_zh = std::env::var("ZAKURO_HOME").ok();
std::env::remove_var("ZAKURO_HOME");
std::env::set_var("HOME", &dir);
let key_a = NodeKey::generate();
let key_b = NodeKey::generate();
let rc = RosterCache::from_entries(vec![
(key_a.public_b64(), false), // authorized
(key_b.public_b64(), true), // revoked
]);
rc.persist();
let loaded = RosterCache::load();
assert!(loaded.is_authorized(&key_a.public_b64()));
assert!(!loaded.is_authorized(&key_b.public_b64())); // still revoked
assert!(loaded.fingerprint_authorized(&key_a.fingerprint()));
assert!(!loaded.fingerprint_authorized(&key_b.fingerprint()));
match prev {
Some(v) => std::env::set_var("HOME", v),
None => std::env::remove_var("HOME"),
}
if let Some(v) = prev_zh {
std::env::set_var("ZAKURO_HOME", v);
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn fingerprint_authorized_matches_node_key_fingerprint() {
let key = NodeKey::generate();
let fp = key.fingerprint();
let helper_fp = fingerprint_of_pubkey_b64(&key.public_b64()).unwrap();
assert_eq!(helper_fp, fp);
let rc = RosterCache::from_entries(vec![(key.public_b64(), false)]);
assert!(rc.fingerprint_authorized(&fp));
let revoked = RosterCache::from_entries(vec![(key.public_b64(), true)]);
assert!(!revoked.fingerprint_authorized(&fp));
let empty = RosterCache::from_entries(vec![]);
assert!(!empty.fingerprint_authorized(&fp));
}
}