urma-runtime 0.3.3

URMA publication planning, network transports and recovery
Documentation
use crate::config::P2P_GOOD_PEERS;
use crate::error::Error;
use crate::p2p::addr::{routable, unix_now};
use serde::{Deserialize, Serialize};
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use urma_chain::observation::Chain;

#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub(super) struct Entry {
    pub(super) address: SocketAddr,
    pub(super) last_seen: u64,
}

pub(super) struct PeerBook {
    path: PathBuf,
    entries: Vec<Entry>,
}

impl PeerBook {
    pub(super) fn open(cache_dir: &Path, chain: Chain) -> Self {
        let path = cache_dir.join(format!("{}-peers.json", chain.label().replace(' ', "-")));
        let mut book = Self {
            entries: Vec::new(),
            path,
        };
        match book.load() {
            Ok(entries) => book.entries = entries,
            Err(error) => {
                tracing::warn!(path = %book.path.display(), %error, "peer book unreadable; starting empty")
            }
        }
        book.trim();
        book
    }

    fn load(&self) -> Result<Vec<Entry>, Error> {
        if !self.path.try_exists()? {
            return Ok(Vec::new());
        }
        let now = unix_now()?;
        let mut entries: Vec<Entry> = serde_json::from_slice(&std::fs::read(&self.path)?)?;
        entries.retain(|entry| routable(&entry.address));
        for entry in &mut entries {
            entry.last_seen = entry.last_seen.min(now);
        }
        Ok(entries)
    }

    pub(super) fn addresses(&self) -> Vec<SocketAddr> {
        self.entries.iter().map(|entry| entry.address).collect()
    }

    pub(super) fn record(&mut self, address: SocketAddr, now: u64) {
        self.entries.retain(|entry| entry.address != address);
        self.entries.push(Entry {
            address,
            last_seen: now,
        });
        self.trim();
        match self.save() {
            Ok(()) => (),
            Err(error) => {
                tracing::warn!(path = %self.path.display(), %error, "peer book not persisted")
            }
        }
    }

    fn trim(&mut self) {
        self.entries
            .sort_by(|left, right| right.last_seen.cmp(&left.last_seen));
        self.entries.truncate(P2P_GOOD_PEERS);
    }

    fn save(&self) -> Result<(), Error> {
        let bytes = serde_json::to_vec(&self.entries)?;
        Ok(urma_io::write_replace(&self.path, &bytes)?)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn address(last: u16) -> SocketAddr {
        let [high, low] = last.to_be_bytes();
        SocketAddr::from(([45, 0, high, low], 19335))
    }

    #[test]
    fn good_peers_persist_most_recent_first_and_stay_bounded() {
        let dir = tempfile::tempdir().unwrap();
        let mut book = PeerBook::open(dir.path(), Chain::LitecoinTestnet);
        assert!(book.addresses().is_empty());
        book.record(address(1), 100);
        book.record(address(2), 200);
        book.record(address(1), 300);
        assert_eq!(book.addresses(), vec![address(1), address(2)]);
        let reopened = PeerBook::open(dir.path(), Chain::LitecoinTestnet);
        assert_eq!(reopened.addresses(), vec![address(1), address(2)]);
        assert!(
            PeerBook::open(dir.path(), Chain::LitecoinMainnet)
                .addresses()
                .is_empty()
        );
        let mut full = PeerBook::open(dir.path(), Chain::LitecoinTestnet);
        let extra = u16::try_from(P2P_GOOD_PEERS + 10).unwrap();
        for index in 0..extra {
            full.record(address(1000 + index), 1000 + u64::from(index));
        }
        let kept = PeerBook::open(dir.path(), Chain::LitecoinTestnet).addresses();
        assert_eq!(kept.len(), P2P_GOOD_PEERS);
        assert_eq!(kept[0], address(1000 + extra - 1));
    }

    #[test]
    fn loading_drops_unroutable_entries_and_clamps_future_sightings() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("Litecoin-testnet-peers.json");
        let now = unix_now().unwrap();
        let stored = vec![
            Entry {
                address: "127.0.0.1:19335".parse().unwrap(),
                last_seen: now,
            },
            Entry {
                address: "192.168.0.7:19335".parse().unwrap(),
                last_seen: now,
            },
            Entry {
                address: address(1),
                last_seen: u64::MAX,
            },
            Entry {
                address: address(2),
                last_seen: now - 10,
            },
        ];
        std::fs::write(&path, serde_json::to_vec(&stored).unwrap()).unwrap();
        let mut book = PeerBook::open(dir.path(), Chain::LitecoinTestnet);
        assert_eq!(book.addresses(), vec![address(1), address(2)]);
        book.record(address(3), now + 1);
        assert_eq!(
            book.addresses()[0],
            address(3),
            "a clamped future sighting no longer outranks a real one"
        );
    }

    #[test]
    fn an_unreadable_book_starts_empty_and_is_rewritten() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("Litecoin-testnet-peers.json");
        std::fs::write(&path, b"not json").unwrap();
        let mut book = PeerBook::open(dir.path(), Chain::LitecoinTestnet);
        assert!(book.addresses().is_empty());
        book.record(address(7), 1);
        assert_eq!(
            PeerBook::open(dir.path(), Chain::LitecoinTestnet).addresses(),
            vec![address(7)]
        );
    }
}