rsurl 0.1.8

A pure-Rust implementation of curl. Library, C FFI, and CLI for HTTP/HTTPS/FTP/FTPS.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
//! `.torrent` metainfo parsing (BEP 3) and infohash computation.

use std::path::{Path, PathBuf};

use purecrypto::hash::{Digest, Sha1};

use crate::error::{Error, Result};

use super::bencode::{self, Value};

fn terr(msg: &str) -> Error {
    Error::BadResponse(format!("torrent: {msg}"))
}

/// Upper bound on a torrent's declared `piece length`. Real-world torrents use
/// 16 KiB–16 MiB; we cap generously at 128 MiB so a hostile torrent cannot make
/// each peer thread pre-allocate a multi-GiB piece buffer before any data
/// arrives (a per-peer memory-exhaustion DoS).
const MAX_PIECE_LENGTH: u64 = 128 * 1024 * 1024;

/// SHA-1 of `data` as a 20-byte array.
pub(crate) fn sha1(data: &[u8]) -> [u8; 20] {
    let mut h = Sha1::new();
    h.update(data);
    let mut out = [0u8; 20];
    out.copy_from_slice(h.finalize().as_ref());
    out
}

/// One file within the torrent (single-file torrents have exactly one, whose
/// `path` is just the torrent name).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FileEntry {
    /// Relative path under the download base (sanitized: no `..`, absolute, or
    /// separator components).
    pub path: PathBuf,
    pub length: u64,
}

/// Parsed `.torrent` metainfo plus the computed infohash.
#[derive(Debug, Clone)]
pub struct Metainfo {
    /// SHA-1 of the bencoded `info` dictionary (the swarm identifier).
    pub info_hash: [u8; 20],
    pub name: String,
    pub piece_length: u64,
    /// SHA-1 of each piece, in order.
    pub pieces: Vec<[u8; 20]>,
    /// Files in the order they tile the linear piece space.
    pub files: Vec<FileEntry>,
    pub total_length: u64,
    /// Tracker announce URLs (announce-list tiers flattened, plus `announce`).
    pub trackers: Vec<String>,
    pub private: bool,
}

impl Metainfo {
    /// Parse a `.torrent` from its raw bytes.
    pub fn from_bytes(torrent: &[u8]) -> Result<Metainfo> {
        let root = bencode::parse(torrent)?;

        // Hash the *original* bytes of the `info` value.
        let mut dec = bencode::Decoder::new(torrent);
        let spans = dec.dict_entry_spans()?;
        let info_range = spans
            .into_iter()
            .find(|(k, _)| k == b"info")
            .map(|(_, r)| r)
            .ok_or_else(|| terr("missing info dictionary"))?;
        let info_hash = sha1(&torrent[info_range]);

        let info = root
            .get(b"info")
            .ok_or_else(|| terr("missing info dictionary"))?;
        let name = info
            .get(b"name")
            .and_then(Value::as_str)
            .ok_or_else(|| terr("missing info.name"))?
            .to_string();
        // The torrent name is itself a path component (top dir / single file
        // name); reject anything that could escape the download base.
        sanitize_component(&name)?;

        let piece_length =
            info.get(b"piece length")
                .and_then(Value::as_int)
                .filter(|&n| n > 0)
                .ok_or_else(|| terr("missing/invalid info.piece length"))? as u64;
        if piece_length > MAX_PIECE_LENGTH {
            return Err(terr("info.piece length exceeds maximum"));
        }

        let pieces_raw = info
            .get(b"pieces")
            .and_then(Value::as_bytes)
            .ok_or_else(|| terr("missing info.pieces"))?;
        if pieces_raw.is_empty() || pieces_raw.len() % 20 != 0 {
            return Err(terr("info.pieces is not a multiple of 20 bytes"));
        }
        let pieces: Vec<[u8; 20]> = pieces_raw
            .chunks_exact(20)
            .map(|c| {
                let mut a = [0u8; 20];
                a.copy_from_slice(c);
                a
            })
            .collect();

        // Single-file (`length`) vs multi-file (`files`).
        let (files, total_length) = if let Some(len) = info.get(b"length").and_then(Value::as_int) {
            if len < 0 {
                return Err(terr("negative file length"));
            }
            (
                vec![FileEntry {
                    path: PathBuf::from(&name),
                    length: len as u64,
                }],
                len as u64,
            )
        } else if let Some(list) = info.get(b"files").and_then(Value::as_list) {
            let mut files = Vec::with_capacity(list.len());
            let mut total: u64 = 0;
            for f in list {
                let len = f
                    .get(b"length")
                    .and_then(Value::as_int)
                    .filter(|&n| n >= 0)
                    .ok_or_else(|| terr("missing/invalid files[].length"))?
                    as u64;
                let comps = f
                    .get(b"path")
                    .and_then(Value::as_list)
                    .ok_or_else(|| terr("missing files[].path"))?;
                let mut rel = PathBuf::new();
                for c in comps {
                    let s = c.as_str().ok_or_else(|| terr("non-utf8 path component"))?;
                    sanitize_component(s)?;
                    rel.push(s);
                }
                if rel.as_os_str().is_empty() {
                    return Err(terr("empty file path"));
                }
                // Files live under the torrent's name directory.
                let path = Path::new(&name).join(rel);
                total = total
                    .checked_add(len)
                    .ok_or_else(|| terr("total length overflow"))?;
                files.push(FileEntry { path, length: len });
            }
            if files.is_empty() {
                return Err(terr("empty files list"));
            }
            (files, total)
        } else {
            return Err(terr("info has neither length nor files"));
        };

        // The piece table must cover exactly the data — including the
        // degenerate total_length == 0 case, which must carry no piece hashes.
        // (A zero-length torrent with >= 2 hashes would otherwise make
        // `piece_size`/`Storage::piece_size` compute `total - start` with
        // `start > total`, underflowing to a bogus huge size.) Note `pieces` is
        // already guaranteed non-empty above, so a zero-length torrent (which
        // must have zero pieces) is consistently rejected here.
        let expected_pieces = if total_length == 0 {
            0
        } else {
            total_length.div_ceil(piece_length) as usize
        };
        if pieces.len() != expected_pieces {
            return Err(terr("piece count does not match total length"));
        }

        // Trackers: announce-list (list of tiers) flattened, then announce.
        let mut trackers = Vec::new();
        if let Some(tiers) = root.get(b"announce-list").and_then(Value::as_list) {
            for tier in tiers {
                if let Some(list) = tier.as_list() {
                    for t in list {
                        if let Some(s) = t.as_str() {
                            trackers.push(s.to_string());
                        }
                    }
                }
            }
        }
        if let Some(a) = root.get(b"announce").and_then(Value::as_str) {
            if !trackers.iter().any(|t| t == a) {
                trackers.push(a.to_string());
            }
        }

        let private = info.get(b"private").and_then(Value::as_int) == Some(1);

        Ok(Metainfo {
            info_hash,
            name,
            piece_length,
            pieces,
            files,
            total_length,
            trackers,
            private,
        })
    }

    /// Build a `Metainfo` from just the raw bencoded `info` dictionary, as
    /// obtained from a peer via `ut_metadata` for a `magnet:` link. The result
    /// carries no trackers (those come from the magnet itself).
    pub fn from_info_dict(info: &[u8]) -> Result<Metainfo> {
        // Wrap as `{ "info": <info> }` so the shared parser — and its
        // span-based infohash over the original `info` bytes — applies
        // unchanged.
        let mut torrent = Vec::with_capacity(info.len() + 8);
        torrent.extend_from_slice(b"d4:info");
        torrent.extend_from_slice(info);
        torrent.push(b'e');
        Metainfo::from_bytes(&torrent)
    }

    pub fn num_pieces(&self) -> usize {
        self.pieces.len()
    }

    /// Byte length of piece `index` (the last piece may be short).
    pub fn piece_size(&self, index: usize) -> u64 {
        if index + 1 < self.pieces.len() {
            self.piece_length
        } else {
            // Last piece.
            let before = self.piece_length * index as u64;
            self.total_length.saturating_sub(before)
        }
    }
}

/// Windows reserved device names (case-insensitive, matched with or without an
/// extension, e.g. `CON`, `nul.txt`). On Windows these alias devices rather
/// than files, so a torrent component bearing one is a surprising-write
/// primitive; we reject them on every platform for reproducible behavior.
const WINDOWS_RESERVED: &[&str] = &[
    "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8",
    "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
];

/// Reject a path component that could escape the download directory or trigger
/// a surprising write target. Windows-specific checks (`:`, reserved device
/// names, trailing dot/space) run on every platform so behavior is identical
/// across hosts.
fn sanitize_component(s: &str) -> Result<()> {
    if s.is_empty()
        || s == "."
        || s == ".."
        || s.contains('/')
        || s.contains('\\')
        || s.contains('\0')
        // `:` is a drive separator (`C:`) or NTFS alternate-data-stream
        // separator (`file.txt:stream`) on Windows.
        || s.contains(':')
        // Windows silently strips trailing dots and spaces, which would let a
        // component resolve to a different name than the one we validated.
        || s.ends_with('.')
        || s.ends_with(' ')
    {
        return Err(terr("unsafe path component"));
    }
    // Reserved device names, with or without an extension (the stem before the
    // first `.` is what Windows matches against).
    let stem = s.split('.').next().unwrap_or(s);
    if WINDOWS_RESERVED
        .iter()
        .any(|r| stem.eq_ignore_ascii_case(r))
    {
        return Err(terr("unsafe path component"));
    }
    Ok(())
}

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

    /// Build a minimal single-file torrent and the infohash we expect (SHA-1 of
    /// the encoded `info` dict).
    fn single_file_torrent() -> (Vec<u8>, [u8; 20]) {
        let mut info = BTreeMap::new();
        info.insert(b"name".to_vec(), Value::Bytes(b"hello.txt".to_vec()));
        info.insert(b"piece length".to_vec(), Value::Int(16384));
        info.insert(b"length".to_vec(), Value::Int(10));
        info.insert(b"pieces".to_vec(), Value::Bytes(vec![0u8; 20]));
        let info_val = Value::Dict(info);
        let expected = sha1(&bencode::encode(&info_val));

        let mut root = BTreeMap::new();
        root.insert(
            b"announce".to_vec(),
            Value::Bytes(b"http://t/announce".to_vec()),
        );
        root.insert(b"info".to_vec(), info_val);
        (bencode::encode(&Value::Dict(root)), expected)
    }

    #[test]
    fn parses_single_file_and_infohash() {
        let (bytes, expected_hash) = single_file_torrent();
        let m = Metainfo::from_bytes(&bytes).unwrap();
        assert_eq!(m.info_hash, expected_hash);
        assert_eq!(m.name, "hello.txt");
        assert_eq!(m.piece_length, 16384);
        assert_eq!(m.total_length, 10);
        assert_eq!(m.files.len(), 1);
        assert_eq!(m.files[0].path, PathBuf::from("hello.txt"));
        assert_eq!(m.num_pieces(), 1);
        assert_eq!(m.piece_size(0), 10);
        assert_eq!(m.trackers, vec!["http://t/announce".to_string()]);
    }

    #[test]
    fn parses_multi_file() {
        let mut info = BTreeMap::new();
        info.insert(b"name".to_vec(), Value::Bytes(b"dir".to_vec()));
        info.insert(b"piece length".to_vec(), Value::Int(4));
        info.insert(b"pieces".to_vec(), Value::Bytes(vec![0u8; 40])); // 2 pieces
        let mkfile = |len: i64, parts: &[&str]| {
            let mut f = BTreeMap::new();
            f.insert(b"length".to_vec(), Value::Int(len));
            f.insert(
                b"path".to_vec(),
                Value::List(
                    parts
                        .iter()
                        .map(|p| Value::Bytes(p.as_bytes().to_vec()))
                        .collect(),
                ),
            );
            Value::Dict(f)
        };
        info.insert(
            b"files".to_vec(),
            Value::List(vec![mkfile(5, &["a.txt"]), mkfile(3, &["sub", "b.txt"])]),
        );
        let mut root = BTreeMap::new();
        root.insert(b"info".to_vec(), Value::Dict(info));
        let bytes = bencode::encode(&Value::Dict(root));

        let m = Metainfo::from_bytes(&bytes).unwrap();
        assert_eq!(m.total_length, 8);
        assert_eq!(m.files.len(), 2);
        assert_eq!(m.files[0].path, PathBuf::from("dir/a.txt"));
        assert_eq!(m.files[1].path, PathBuf::from("dir/sub/b.txt"));
        assert_eq!(m.num_pieces(), 2);
        assert_eq!(m.piece_size(0), 4);
        assert_eq!(m.piece_size(1), 4);
    }

    /// A hostile torrent declaring total_length == 0 but carrying piece hashes
    /// must be rejected, so `piece_size` can never see `start > total`.
    #[test]
    fn rejects_zero_length_with_pieces() {
        let mut info = BTreeMap::new();
        info.insert(b"name".to_vec(), Value::Bytes(b"hello.txt".to_vec()));
        info.insert(b"piece length".to_vec(), Value::Int(16384));
        info.insert(b"length".to_vec(), Value::Int(0));
        info.insert(b"pieces".to_vec(), Value::Bytes(vec![0u8; 40])); // 2 hashes
        let mut root = BTreeMap::new();
        root.insert(b"info".to_vec(), Value::Dict(info));
        let bytes = bencode::encode(&Value::Dict(root));
        assert!(Metainfo::from_bytes(&bytes).is_err());
    }

    /// A torrent declaring a piece length above the cap is rejected (per-peer
    /// pre-allocation DoS).
    #[test]
    fn rejects_oversized_piece_length() {
        let mut info = BTreeMap::new();
        info.insert(b"name".to_vec(), Value::Bytes(b"hello.txt".to_vec()));
        info.insert(
            b"piece length".to_vec(),
            Value::Int(MAX_PIECE_LENGTH as i64 + 1),
        );
        info.insert(b"length".to_vec(), Value::Int(10));
        info.insert(b"pieces".to_vec(), Value::Bytes(vec![0u8; 20]));
        let mut root = BTreeMap::new();
        root.insert(b"info".to_vec(), Value::Dict(info));
        let bytes = bencode::encode(&Value::Dict(root));
        assert!(Metainfo::from_bytes(&bytes).is_err());
    }

    #[test]
    fn sanitize_component_rejects_windows_hazards() {
        // Drive-letter / NTFS alternate-data-stream separator.
        assert!(sanitize_component("C:").is_err());
        assert!(sanitize_component("file.txt:stream").is_err());
        // Reserved device names, bare and with an extension, any case.
        assert!(sanitize_component("CON").is_err());
        assert!(sanitize_component("nul.txt").is_err());
        assert!(sanitize_component("Com1").is_err());
        assert!(sanitize_component("LPT9").is_err());
        // Trailing dot / space (Windows strips these).
        assert!(sanitize_component("name.").is_err());
        assert!(sanitize_component("name ").is_err());
        // A plain component is still accepted.
        assert!(sanitize_component("readme.txt").is_ok());
        assert!(sanitize_component("console.log").is_ok());
    }

    #[test]
    fn rejects_path_traversal() {
        let mut info = BTreeMap::new();
        info.insert(b"name".to_vec(), Value::Bytes(b"dir".to_vec()));
        info.insert(b"piece length".to_vec(), Value::Int(4));
        info.insert(b"pieces".to_vec(), Value::Bytes(vec![0u8; 20]));
        let mut f = BTreeMap::new();
        f.insert(b"length".to_vec(), Value::Int(1));
        f.insert(
            b"path".to_vec(),
            Value::List(vec![
                Value::Bytes(b"..".to_vec()),
                Value::Bytes(b"etc".to_vec()),
            ]),
        );
        info.insert(b"files".to_vec(), Value::List(vec![Value::Dict(f)]));
        let mut root = BTreeMap::new();
        root.insert(b"info".to_vec(), Value::Dict(info));
        let bytes = bencode::encode(&Value::Dict(root));
        assert!(Metainfo::from_bytes(&bytes).is_err());
    }
}