Skip to main content

irontide_session_core/
persistence.rs

1use serde::{Deserialize, Serialize};
2
3/// A DHT bootstrap node entry for session persistence.
4#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
5pub struct DhtNodeEntry {
6    /// Hostname or IP address of the DHT node.
7    pub host: String,
8    /// Port number of the DHT node.
9    pub port: i64,
10}
11
12/// A peer strike entry for session persistence.
13#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14pub struct PeerStrikeEntry {
15    /// IP address of the peer that received strikes.
16    pub ip: String,
17    /// Number of accumulated strikes.
18    pub count: i64,
19}
20
21/// Persisted session state containing a DHT node cache and torrent resume data.
22///
23/// Serializes to bencode for on-disk persistence. The DHT node list allows
24/// faster bootstrapping on restart, and the torrent list holds
25/// [`irontide_core::FastResumeData`] entries so torrents can skip piece
26/// verification when the bitfield matches.
27#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
28pub struct SessionState {
29    /// Cached DHT routing table nodes for faster bootstrap on restart.
30    #[serde(rename = "dht-nodes", default)]
31    pub dht_nodes: Vec<DhtNodeEntry>,
32    /// BEP 42-compliant DHT node ID (hex). Persisted so the routing table
33    /// survives across sessions without regeneration.
34    #[serde(
35        rename = "dht-node-id",
36        default,
37        skip_serializing_if = "Option::is_none"
38    )]
39    pub dht_node_id: Option<String>,
40    /// Fast resume data for each torrent in the session.
41    #[serde(rename = "torrents", default)]
42    pub torrents: Vec<irontide_core::FastResumeData>,
43    /// IP addresses of permanently banned peers.
44    #[serde(rename = "banned-peers", default)]
45    pub banned_peers: Vec<String>,
46    /// Per-peer strike counts for the smart ban system.
47    #[serde(rename = "peer-strikes", default)]
48    pub peer_strikes: Vec<PeerStrikeEntry>,
49}
50
51impl SessionState {
52    /// Create a new empty `SessionState`.
53    #[must_use]
54    pub fn new() -> Self {
55        Self {
56            dht_nodes: Vec::new(),
57            dht_node_id: None,
58            torrents: Vec::new(),
59            banned_peers: Vec::new(),
60            peer_strikes: Vec::new(),
61        }
62    }
63}
64
65impl Default for SessionState {
66    fn default() -> Self {
67        Self::new()
68    }
69}
70
71#[cfg(test)]
72mod tests {
73    use super::*;
74    use pretty_assertions::assert_eq;
75
76    #[test]
77    fn session_state_bencode_round_trip() {
78        let state = SessionState {
79            dht_nodes: vec![
80                DhtNodeEntry {
81                    host: "router.bittorrent.com".into(),
82                    port: 6881,
83                },
84                DhtNodeEntry {
85                    host: "dht.transmissionbt.com".into(),
86                    port: 6881,
87                },
88            ],
89            dht_node_id: None,
90            torrents: vec![irontide_core::FastResumeData::new(
91                vec![0xAA; 20],
92                "test-torrent".into(),
93                "/downloads".into(),
94            )],
95            banned_peers: Vec::new(),
96            peer_strikes: Vec::new(),
97        };
98
99        let encoded = irontide_bencode::to_bytes(&state).unwrap();
100        let decoded: SessionState = irontide_bencode::from_bytes(&encoded).unwrap();
101        assert_eq!(state, decoded);
102    }
103
104    #[test]
105    fn session_state_with_node_id_round_trip() {
106        let state = SessionState {
107            dht_nodes: vec![DhtNodeEntry {
108                host: "1.2.3.4".into(),
109                port: 6881,
110            }],
111            dht_node_id: Some("26d8457c04424098fd9e615b297745c772f49706".into()),
112            torrents: vec![],
113            banned_peers: vec![],
114            peer_strikes: vec![],
115        };
116
117        let encoded = irontide_bencode::to_bytes(&state).unwrap();
118        let encoded_str = String::from_utf8_lossy(&encoded);
119        assert!(
120            encoded_str.contains("dht-node-id"),
121            "encoded bencode should contain dht-node-id key: {encoded_str}"
122        );
123
124        let decoded: SessionState = irontide_bencode::from_bytes(&encoded).unwrap();
125        assert_eq!(state.dht_node_id, decoded.dht_node_id);
126    }
127
128    #[test]
129    fn empty_session_state_round_trip() {
130        let state = SessionState::new();
131
132        let encoded = irontide_bencode::to_bytes(&state).unwrap();
133        let decoded: SessionState = irontide_bencode::from_bytes(&encoded).unwrap();
134        assert_eq!(state, decoded);
135    }
136
137    #[test]
138    fn session_state_with_bans_round_trip() {
139        let state = SessionState {
140            dht_nodes: vec![],
141            dht_node_id: None,
142            torrents: vec![],
143            banned_peers: vec!["10.0.0.1".into(), "192.168.1.5".into()],
144            peer_strikes: vec![
145                PeerStrikeEntry {
146                    ip: "10.0.0.1".into(),
147                    count: 3,
148                },
149                PeerStrikeEntry {
150                    ip: "10.0.0.2".into(),
151                    count: 1,
152                },
153            ],
154        };
155
156        let encoded = irontide_bencode::to_bytes(&state).unwrap();
157        let decoded: SessionState = irontide_bencode::from_bytes(&encoded).unwrap();
158        assert_eq!(state, decoded);
159        assert_eq!(decoded.banned_peers.len(), 2);
160        assert_eq!(decoded.peer_strikes.len(), 2);
161    }
162
163    #[test]
164    fn session_state_backward_compatible() {
165        // Old format without ban fields — should deserialize cleanly with defaults
166        let old_state = SessionState {
167            dht_nodes: vec![DhtNodeEntry {
168                host: "example.com".into(),
169                port: 6881,
170            }],
171            dht_node_id: None,
172            torrents: vec![],
173            banned_peers: vec![],
174            peer_strikes: vec![],
175        };
176        let encoded = irontide_bencode::to_bytes(&old_state).unwrap();
177
178        // Manually create bencode without banned-peers/peer-strikes to simulate old format
179        // Since #[serde(default)] is used, decoding old data missing those fields works
180        let decoded: SessionState = irontide_bencode::from_bytes(&encoded).unwrap();
181        assert!(decoded.banned_peers.is_empty());
182        assert!(decoded.peer_strikes.is_empty());
183        assert_eq!(decoded.dht_nodes.len(), 1);
184    }
185}