zc2 0.0.25

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,
    /// `(pubkey_b64, "ip:port")`. `#[serde(default)]` is load-bearing: a
    /// roster.json written by an older zc has no such key, and failing to
    /// parse it would drop the node's offline authorization cache.
    #[serde(default)]
    endpoints: Vec<(String, String)>,
}

/// Cached snapshot of the dashboard's authorized-node roster.
#[derive(Debug, Clone, Default)]
pub struct RosterCache {
    entries: Vec<(String, bool)>,
    fetched_at: u64,
    endpoints: Vec<(String, String)>,
}

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(),
            endpoints: Vec::new(),
        }
    }

    /// 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()
    }

    /// Replace the known peer endpoints.
    pub fn set_endpoints(&mut self, endpoints: Vec<(String, String)>) {
        self.endpoints = endpoints;
    }

    /// Address of the non-revoked roster node whose fingerprint is `fp`.
    ///
    /// Revoked nodes are skipped so a de-authorized peer is not merely
    /// distrusted but unaddressable -- revocation and reachability are the
    /// same row on purpose.
    pub fn endpoint_for_fingerprint(&self, fp: &str) -> Option<String> {
        self.endpoints.iter().find_map(|(pk, ep)| {
            let matches = fingerprint_of_pubkey_b64(pk)
                .map(|f| f == fp)
                .unwrap_or(false);
            if matches && self.is_authorized(pk) {
                Some(ep.clone())
            } else {
                None
            }
        })
    }

    /// 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 Some(json) = self.to_json() else {
            return;
        };
        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));
            }
        }
    }

    /// Serialize this cache to the on-disk `RosterFile` JSON shape. Split out
    /// of `persist()` so tests can exercise the write side directly (paired
    /// with `load_from_str` for the read side) without touching the
    /// filesystem or `credentials::dir()`.
    fn to_json(&self) -> Option<String> {
        let file = RosterFile {
            entries: self.entries.clone(),
            fetched_at: self.fetched_at,
            endpoints: self.endpoints.clone(),
        };
        serde_json::to_string(&file).ok()
    }

    /// 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();
        };
        Self::load_from_str(&text).unwrap_or_default()
    }

    /// Parse a roster.json's text content into a `RosterCache`. Shared by
    /// `load()` and by tests that want to exercise the real deserialization
    /// path (not just the raw `RosterFile` struct) against a specific file's
    /// bytes without touching the filesystem location `load()` reads from.
    fn load_from_str(text: &str) -> Option<RosterCache> {
        serde_json::from_str::<RosterFile>(text)
            .ok()
            .map(|file| RosterCache {
                entries: file.entries,
                fetched_at: file.fetched_at,
                endpoints: file.endpoints,
            })
    }
}

#[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));
    }

    #[test]
    fn parses_legacy_roster_file_without_endpoints() {
        // A file written by an older zc. If this ever returns an empty
        // roster, every upgraded node silently loses its offline
        // authorization set and starts rejecting peers it should trust.
        let legacy = r#"{"entries":[["PUBKEY_A",false],["PUBKEY_B",true]],"fetched_at":1}"#;
        let f: super::RosterFile = serde_json::from_str(legacy).expect("legacy must parse");
        assert_eq!(f.entries.len(), 2);
        assert!(f.endpoints.is_empty());
    }

    #[test]
    fn endpoint_lookup_is_by_fingerprint_and_skips_revoked() {
        use super::super::node_identity::NodeKey;
        let key = NodeKey::generate();
        let pk = key.public_b64();
        let fp = super::super::node_identity::fingerprint_of_pubkey_b64(&pk).unwrap();

        let mut c = super::RosterCache::from_entries(vec![(pk.clone(), false)]);
        c.set_endpoints(vec![(pk.clone(), "10.13.13.6:9000".to_string())]);
        assert_eq!(
            c.endpoint_for_fingerprint(&fp).as_deref(),
            Some("10.13.13.6:9000")
        );

        let mut revoked = super::RosterCache::from_entries(vec![(pk.clone(), true)]);
        revoked.set_endpoints(vec![(pk, "10.13.13.6:9000".to_string())]);
        assert_eq!(revoked.endpoint_for_fingerprint(&fp), None);
    }

    /// The acceptance check for this task, made durable: a SYNTHETIC
    /// 127-entry legacy-shape roster (`{"entries":[[pubkey,bool],...],
    /// "fetched_at":N}`, no `endpoints` key -- the exact shape a pre-Task-7
    /// zc writes) must still deserialize to all 127 entries. This never
    /// reads the user's real `~/.zakuro/roster.json`: that file holds 127
    /// real node public keys, and neither snapshotting it into the repo nor
    /// depending on its live, mutable entry count belongs in checked-in
    /// source (it would flake the moment that cache refreshes, and would be
    /// skipped-not-failed on any other machine or CI runner).
    #[test]
    fn synthetic_127_entry_legacy_roster_survives_deserialization() {
        let entries: Vec<(String, bool)> = (0..127)
            .map(|i| (format!("PUBKEY_{i}"), i % 5 == 0))
            .collect();
        let entries_json = entries
            .iter()
            .map(|(pk, revoked)| format!(r#"["{pk}",{revoked}]"#))
            .collect::<Vec<_>>()
            .join(",");
        let legacy = format!(r#"{{"entries":[{entries_json}],"fetched_at":1}}"#);

        let file: super::RosterFile =
            serde_json::from_str(&legacy).expect("synthetic legacy roster must parse");
        assert_eq!(file.entries.len(), 127);
        assert!(file.endpoints.is_empty());

        let rc = super::RosterCache::load_from_str(&legacy)
            .expect("must parse via RosterCache path too");
        assert_eq!(rc.entries().len(), 127);
    }

    /// Finding 1 (round-trip coverage): before this test, nothing exercised
    /// `to_json()` (persist's write side) with a NON-EMPTY endpoints list
    /// and fed the result back through `load_from_str` (load's read side).
    /// So a future edit that dropped `endpoints: self.endpoints.clone()` in
    /// `to_json`, or `endpoints: file.endpoints` in `load_from_str`, would
    /// have left every existing test green while silently losing endpoints
    /// across every restart. This closes that gap without touching the
    /// filesystem (unlike `persist_then_load_roundtrips_roster_entries`,
    /// which covers the actual file I/O but predates the `endpoints` field
    /// and only asserts on entries).
    #[test]
    fn to_json_then_load_from_str_roundtrips_endpoints_and_entries() {
        let key_a = NodeKey::generate();
        let key_b = NodeKey::generate();
        let fp_a = key_a.fingerprint();

        let mut rc = RosterCache::from_entries(vec![
            (key_a.public_b64(), false), // authorized
            (key_b.public_b64(), true),  // revoked
        ]);
        rc.set_endpoints(vec![
            (key_a.public_b64(), "10.13.13.6:9000".to_string()),
            (key_b.public_b64(), "10.13.13.7:9000".to_string()),
        ]);

        let json = rc.to_json().expect("to_json must succeed");
        let loaded =
            RosterCache::load_from_str(&json).expect("load_from_str must parse to_json's output");

        // entries survive
        assert!(loaded.is_authorized(&key_a.public_b64()));
        assert!(!loaded.is_authorized(&key_b.public_b64()));
        // endpoints survive, and revocation still makes key_b unaddressable
        assert_eq!(
            loaded.endpoint_for_fingerprint(&fp_a).as_deref(),
            Some("10.13.13.6:9000")
        );
        assert_eq!(loaded.endpoint_for_fingerprint(&key_b.fingerprint()), None);
    }
}