Skip to main content

irontide_session/
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/// Returns `true` if the `pieces` bitfield has the correct length for
72/// `num_pieces` pieces (i.e. `ceil(num_pieces / 8)` bytes).
73///
74/// This is used to decide whether a resume file's piece bitfield is
75/// trustworthy and hash verification can be skipped on restart.
76#[must_use]
77pub fn validate_resume_bitfield(pieces: &[u8], num_pieces: u32) -> bool {
78    if num_pieces == 0 {
79        return pieces.is_empty();
80    }
81    let expected = num_pieces.div_ceil(8) as usize;
82    pieces.len() == expected
83}
84
85#[cfg(test)]
86mod tests {
87    use super::*;
88    use pretty_assertions::assert_eq;
89
90    #[test]
91    fn session_state_bencode_round_trip() {
92        let state = SessionState {
93            dht_nodes: vec![
94                DhtNodeEntry {
95                    host: "router.bittorrent.com".into(),
96                    port: 6881,
97                },
98                DhtNodeEntry {
99                    host: "dht.transmissionbt.com".into(),
100                    port: 6881,
101                },
102            ],
103            dht_node_id: None,
104            torrents: vec![irontide_core::FastResumeData::new(
105                vec![0xAA; 20],
106                "test-torrent".into(),
107                "/downloads".into(),
108            )],
109            banned_peers: Vec::new(),
110            peer_strikes: Vec::new(),
111        };
112
113        let encoded = irontide_bencode::to_bytes(&state).unwrap();
114        let decoded: SessionState = irontide_bencode::from_bytes(&encoded).unwrap();
115        assert_eq!(state, decoded);
116    }
117
118    #[test]
119    fn session_state_with_node_id_round_trip() {
120        let state = SessionState {
121            dht_nodes: vec![DhtNodeEntry {
122                host: "1.2.3.4".into(),
123                port: 6881,
124            }],
125            dht_node_id: Some("26d8457c04424098fd9e615b297745c772f49706".into()),
126            torrents: vec![],
127            banned_peers: vec![],
128            peer_strikes: vec![],
129        };
130
131        let encoded = irontide_bencode::to_bytes(&state).unwrap();
132        let encoded_str = String::from_utf8_lossy(&encoded);
133        assert!(
134            encoded_str.contains("dht-node-id"),
135            "encoded bencode should contain dht-node-id key: {encoded_str}"
136        );
137
138        let decoded: SessionState = irontide_bencode::from_bytes(&encoded).unwrap();
139        assert_eq!(state.dht_node_id, decoded.dht_node_id);
140    }
141
142    #[test]
143    fn empty_session_state_round_trip() {
144        let state = SessionState::new();
145
146        let encoded = irontide_bencode::to_bytes(&state).unwrap();
147        let decoded: SessionState = irontide_bencode::from_bytes(&encoded).unwrap();
148        assert_eq!(state, decoded);
149    }
150
151    #[test]
152    fn validate_resume_bitfield_correct_length() {
153        // 8 pieces -> 1 byte
154        assert!(validate_resume_bitfield(&[0xFF], 8));
155        // 9 pieces -> 2 bytes
156        assert!(validate_resume_bitfield(&[0xFF, 0x80], 9));
157        // 16 pieces -> 2 bytes
158        assert!(validate_resume_bitfield(&[0xFF, 0xFF], 16));
159        // 1 piece -> 1 byte
160        assert!(validate_resume_bitfield(&[0x80], 1));
161    }
162
163    #[test]
164    fn validate_resume_bitfield_wrong_length() {
165        // 8 pieces with 2 bytes -> wrong
166        assert!(!validate_resume_bitfield(&[0xFF, 0x00], 8));
167        // 9 pieces with 1 byte -> wrong
168        assert!(!validate_resume_bitfield(&[0xFF], 9));
169        // 0 pieces with 1 byte of data -> wrong
170        assert!(!validate_resume_bitfield(&[0x00], 0));
171    }
172
173    #[test]
174    fn validate_resume_bitfield_zero_pieces() {
175        // 0 pieces with empty data -> true
176        assert!(validate_resume_bitfield(&[], 0));
177    }
178
179    #[test]
180    fn session_state_with_bans_round_trip() {
181        let state = SessionState {
182            dht_nodes: vec![],
183            dht_node_id: None,
184            torrents: vec![],
185            banned_peers: vec!["10.0.0.1".into(), "192.168.1.5".into()],
186            peer_strikes: vec![
187                PeerStrikeEntry {
188                    ip: "10.0.0.1".into(),
189                    count: 3,
190                },
191                PeerStrikeEntry {
192                    ip: "10.0.0.2".into(),
193                    count: 1,
194                },
195            ],
196        };
197
198        let encoded = irontide_bencode::to_bytes(&state).unwrap();
199        let decoded: SessionState = irontide_bencode::from_bytes(&encoded).unwrap();
200        assert_eq!(state, decoded);
201        assert_eq!(decoded.banned_peers.len(), 2);
202        assert_eq!(decoded.peer_strikes.len(), 2);
203    }
204
205    #[test]
206    fn session_state_backward_compatible() {
207        // Old format without ban fields — should deserialize cleanly with defaults
208        let old_state = SessionState {
209            dht_nodes: vec![DhtNodeEntry {
210                host: "example.com".into(),
211                port: 6881,
212            }],
213            dht_node_id: None,
214            torrents: vec![],
215            banned_peers: vec![],
216            peer_strikes: vec![],
217        };
218        let encoded = irontide_bencode::to_bytes(&old_state).unwrap();
219
220        // Manually create bencode without banned-peers/peer-strikes to simulate old format
221        // Since #[serde(default)] is used, decoding old data missing those fields works
222        let decoded: SessionState = irontide_bencode::from_bytes(&encoded).unwrap();
223        assert!(decoded.banned_peers.is_empty());
224        assert!(decoded.peer_strikes.is_empty());
225        assert_eq!(decoded.dht_nodes.len(), 1);
226    }
227}