zc2 0.0.23

P2P compute broker with credit-based billing, WAL, and broker mesh support
//! 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));
    }
}