Skip to main content

irontide_core/
file_tree.rs

1#![allow(
2    clippy::cast_possible_truncation,
3    clippy::cast_possible_wrap,
4    clippy::cast_sign_loss,
5    reason = "M175: BEP 52 file tree — piece counts bounded by torrent size"
6)]
7
8//! BEP 52 v2 file tree types and parsing.
9//!
10//! The v2 file tree is a nested dict where path components are dict keys
11//! and the empty string `""` holds file attributes. This cannot use serde
12//! derive because keys are arbitrary filenames — requires manual
13//! `BencodeValue::Dict` walking.
14
15use std::collections::BTreeMap;
16
17use irontide_bencode::BencodeValue;
18
19use crate::error::Error;
20use crate::hash::Id32;
21
22/// Attributes of a single file in a v2 file tree.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct V2FileAttr {
25    /// File length in bytes.
26    pub length: u64,
27    /// Merkle root of the file's block hashes. `None` for empty files (length == 0).
28    pub pieces_root: Option<Id32>,
29}
30
31/// A node in the v2 file tree — either a file or a directory.
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub enum FileTreeNode {
34    /// A file with its attributes.
35    File(V2FileAttr),
36    /// A directory containing named children.
37    Directory(BTreeMap<String, Self>),
38}
39
40/// Flattened file info from a v2 file tree.
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct V2FileInfo {
43    /// Path components from the tree root to this file.
44    pub path: Vec<String>,
45    /// File attributes.
46    pub attr: V2FileAttr,
47}
48
49impl FileTreeNode {
50    /// Parse a file tree node from a `BencodeValue`.
51    ///
52    /// A node is a dict. If it contains a `""` key, it's a file node (the `""`
53    /// value holds file attributes). Otherwise, each key is a directory entry
54    /// name and its value is a child `FileTreeNode`.
55    ///
56    /// # Errors
57    ///
58    /// Returns an error if the bencode value is not a valid file tree node.
59    pub fn from_bencode(value: &BencodeValue) -> Result<Self, Error> {
60        let dict = value
61            .as_dict()
62            .ok_or_else(|| Error::InvalidTorrent("file tree node must be a dict".into()))?;
63
64        // Check for the empty-string key which signals a file node
65        if let Some(attr_value) = dict.get(b"".as_ref()) {
66            let attr = parse_file_attr(attr_value)?;
67            return Ok(Self::File(attr));
68        }
69
70        // Otherwise it's a directory — each key is a child name
71        let mut children = BTreeMap::new();
72        for (key, child_value) in dict {
73            let name = String::from_utf8(key.clone())
74                .map_err(|_| Error::InvalidTorrent("file tree key is not valid UTF-8".into()))?;
75            let child = Self::from_bencode(child_value)?;
76            children.insert(name, child);
77        }
78
79        Ok(Self::Directory(children))
80    }
81
82    /// Recursively flatten the tree into a list of files with full paths.
83    #[must_use]
84    pub fn flatten(&self) -> Vec<V2FileInfo> {
85        let mut result = Vec::new();
86        self.flatten_into(&mut result, &mut Vec::new());
87        result
88    }
89
90    fn flatten_into(&self, result: &mut Vec<V2FileInfo>, path: &mut Vec<String>) {
91        match self {
92            Self::File(attr) => {
93                result.push(V2FileInfo {
94                    path: path.clone(),
95                    attr: attr.clone(),
96                });
97            }
98            Self::Directory(children) => {
99                for (name, child) in children {
100                    path.push(name.clone());
101                    child.flatten_into(result, path);
102                    path.pop();
103                }
104            }
105        }
106    }
107
108    /// Convert a file tree back to a `BencodeValue` for serialization.
109    ///
110    /// Used by hybrid torrent creation to build the merged info dict.
111    #[must_use]
112    pub fn to_bencode(&self) -> BencodeValue {
113        match self {
114            Self::File(attr) => {
115                let mut file_dict = BTreeMap::new();
116                file_dict.insert(
117                    b"length".to_vec(),
118                    BencodeValue::Integer(attr.length as i64),
119                );
120                if let Some(root) = &attr.pieces_root {
121                    file_dict.insert(
122                        b"pieces root".to_vec(),
123                        BencodeValue::Bytes(root.as_bytes().to_vec()),
124                    );
125                }
126                // Wrap in the "" key that signals a file node
127                let mut node = BTreeMap::new();
128                node.insert(b"".to_vec(), BencodeValue::Dict(file_dict));
129                BencodeValue::Dict(node)
130            }
131            Self::Directory(children) => {
132                let mut dict = BTreeMap::new();
133                for (name, child) in children {
134                    dict.insert(name.as_bytes().to_vec(), child.to_bencode());
135                }
136                BencodeValue::Dict(dict)
137            }
138        }
139    }
140}
141
142/// Parse file attributes from the `""` key's value dict.
143fn parse_file_attr(value: &BencodeValue) -> Result<V2FileAttr, Error> {
144    let dict = value
145        .as_dict()
146        .ok_or_else(|| Error::InvalidTorrent("file attr must be a dict".into()))?;
147
148    let length = dict
149        .get(b"length".as_ref())
150        .and_then(irontide_bencode::BencodeValue::as_int)
151        .ok_or_else(|| Error::InvalidTorrent("file attr missing 'length'".into()))?;
152
153    if length < 0 {
154        return Err(Error::InvalidTorrent(format!(
155            "file attr has negative length: {length}"
156        )));
157    }
158
159    let pieces_root = if let Some(root_val) = dict.get(b"pieces root".as_ref()) {
160        let bytes = root_val
161            .as_bytes_raw()
162            .ok_or_else(|| Error::InvalidTorrent("pieces root must be bytes".into()))?;
163        Some(Id32::from_bytes(bytes)?)
164    } else {
165        None
166    };
167
168    Ok(V2FileAttr {
169        length: length as u64,
170        pieces_root,
171    })
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    /// Helper to build a `BencodeValue` dict from key-value pairs.
179    fn bdict(pairs: Vec<(&[u8], BencodeValue)>) -> BencodeValue {
180        let mut map = BTreeMap::new();
181        for (k, v) in pairs {
182            map.insert(k.to_vec(), v);
183        }
184        BencodeValue::Dict(map)
185    }
186
187    fn bint(v: i64) -> BencodeValue {
188        BencodeValue::Integer(v)
189    }
190
191    fn bbytes(v: &[u8]) -> BencodeValue {
192        BencodeValue::Bytes(v.to_vec())
193    }
194
195    #[test]
196    fn single_file() {
197        // { "test.txt": { "": { "length": 1024, "pieces root": <32 bytes> } } }
198        let root_hash = [0xABu8; 32];
199        let tree = bdict(vec![(
200            b"test.txt",
201            bdict(vec![(
202                b"",
203                bdict(vec![
204                    (b"length", bint(1024)),
205                    (b"pieces root", bbytes(&root_hash)),
206                ]),
207            )]),
208        )]);
209
210        let node = FileTreeNode::from_bencode(&tree).unwrap();
211        let files = node.flatten();
212        assert_eq!(files.len(), 1);
213        assert_eq!(files[0].path, vec!["test.txt"]);
214        assert_eq!(files[0].attr.length, 1024);
215        assert_eq!(files[0].attr.pieces_root, Some(Id32(root_hash)));
216    }
217
218    #[test]
219    fn nested_directory() {
220        // { "dir": { "subfile.dat": { "": { "length": 512 } } } }
221        let tree = bdict(vec![(
222            b"dir",
223            bdict(vec![(
224                b"subfile.dat",
225                bdict(vec![(b"", bdict(vec![(b"length", bint(512))]))]),
226            )]),
227        )]);
228
229        let node = FileTreeNode::from_bencode(&tree).unwrap();
230        let files = node.flatten();
231        assert_eq!(files.len(), 1);
232        assert_eq!(files[0].path, vec!["dir", "subfile.dat"]);
233        assert_eq!(files[0].attr.length, 512);
234        assert_eq!(files[0].attr.pieces_root, None);
235    }
236
237    #[test]
238    fn multiple_files_btreemap_ordering() {
239        // BTreeMap sorts lexicographically, so "alpha" < "beta"
240        let tree = bdict(vec![
241            (
242                b"beta.txt",
243                bdict(vec![(b"", bdict(vec![(b"length", bint(200))]))]),
244            ),
245            (
246                b"alpha.txt",
247                bdict(vec![(b"", bdict(vec![(b"length", bint(100))]))]),
248            ),
249        ]);
250
251        let node = FileTreeNode::from_bencode(&tree).unwrap();
252        let files = node.flatten();
253        assert_eq!(files.len(), 2);
254        // BTreeMap iterates in sorted order
255        assert_eq!(files[0].path, vec!["alpha.txt"]);
256        assert_eq!(files[1].path, vec!["beta.txt"]);
257    }
258
259    #[test]
260    fn reject_missing_length() {
261        // File attr without "length" key
262        let tree = bdict(vec![(
263            b"bad.txt",
264            bdict(vec![(
265                b"",
266                bdict(vec![(b"pieces root", bbytes(&[0u8; 32]))]),
267            )]),
268        )]);
269
270        assert!(FileTreeNode::from_bencode(&tree).is_err());
271    }
272
273    #[test]
274    fn reject_non_dict() {
275        let value = BencodeValue::Integer(42);
276        assert!(FileTreeNode::from_bencode(&value).is_err());
277    }
278
279    #[test]
280    fn empty_file_no_pieces_root() {
281        // Empty files (length == 0) should not have pieces_root
282        let tree = bdict(vec![(
283            b"empty.txt",
284            bdict(vec![(b"", bdict(vec![(b"length", bint(0))]))]),
285        )]);
286
287        let node = FileTreeNode::from_bencode(&tree).unwrap();
288        let files = node.flatten();
289        assert_eq!(files.len(), 1);
290        assert_eq!(files[0].attr.length, 0);
291        assert_eq!(files[0].attr.pieces_root, None);
292    }
293}