Skip to main content

par2_rs/packet/
file_desc.rs

1use crate::error::{Par2Error, Result};
2use crate::path::translate_par2_name_to_relative;
3use crate::types::FileId;
4
5const FIXED_BODY_SIZE: usize = 56;
6const MAX_FILENAME_BYTES: usize = 100_000;
7
8/// Parsed File Description packet.
9#[derive(Debug, Clone)]
10pub struct FileDescriptionPacket {
11    /// MD5-based file identifier: MD5(hash_16k || length || name).
12    pub file_id: FileId,
13    /// MD5 of the entire file.
14    pub hash_full: [u8; 16],
15    /// MD5 of the first 16,384 bytes of the file.
16    pub hash_16k: [u8; 16],
17    /// File length in bytes.
18    pub file_length: u64,
19    /// Filename (ASCII/UTF-8, without null terminator).
20    pub filename: String,
21    /// Original PAR2 filename before filesystem-safe translation.
22    pub par2_name: String,
23}
24
25impl FileDescriptionPacket {
26    /// Parse a File Description packet from its body (after the 64-byte header).
27    pub fn parse(body: &[u8]) -> Result<Self> {
28        // Minimum body: 16 (file_id) + 16 (hash_full) + 16 (hash_16k) + 8 (length) = 56
29        // plus at least one filename byte.
30        if body.len() <= FIXED_BODY_SIZE {
31            return Err(Par2Error::InvalidFileDescPacket {
32                reason: format!(
33                    "body too short: {} bytes, need more than {FIXED_BODY_SIZE}",
34                    body.len()
35                ),
36            });
37        }
38
39        let file_id = FileId::from_bytes(body[0..16].try_into().unwrap());
40        let hash_full: [u8; 16] = body[16..32].try_into().unwrap();
41        let hash_16k: [u8; 16] = body[32..48].try_into().unwrap();
42        let file_length = u64::from_le_bytes(body[48..56].try_into().unwrap());
43        if file_length <= 16_384 && hash_16k != hash_full {
44            return Err(Par2Error::InvalidFileDescPacket {
45                reason: "hash_16k must match hash_full for files <= 16KiB".to_string(),
46            });
47        }
48
49        // Filename: everything after offset 56, strip null padding
50        let name_bytes = &body[56..];
51        if name_bytes.len() > MAX_FILENAME_BYTES {
52            return Err(Par2Error::InvalidFileDescPacket {
53                reason: format!("filename payload too large: {} bytes", name_bytes.len()),
54            });
55        }
56        let name_end = name_bytes
57            .iter()
58            .position(|&b| b == 0)
59            .unwrap_or(name_bytes.len());
60        if name_end == 0 {
61            return Err(Par2Error::InvalidFileDescPacket {
62                reason: "filename is empty".to_string(),
63            });
64        }
65        let par2_name = String::from_utf8_lossy(&name_bytes[..name_end]).into_owned();
66        let filename = translate_par2_name_to_relative(&par2_name)?;
67
68        Ok(FileDescriptionPacket {
69            file_id,
70            hash_full,
71            hash_16k,
72            file_length,
73            filename,
74            par2_name,
75        })
76    }
77}
78
79#[cfg(test)]
80mod tests {
81    use super::*;
82
83    fn make_file_desc_body(
84        file_id: [u8; 16],
85        hash_full: [u8; 16],
86        hash_16k: [u8; 16],
87        file_length: u64,
88        filename: &[u8],
89    ) -> Vec<u8> {
90        let mut body = Vec::new();
91        body.extend_from_slice(&file_id);
92        body.extend_from_slice(&hash_full);
93        body.extend_from_slice(&hash_16k);
94        body.extend_from_slice(&file_length.to_le_bytes());
95        body.extend_from_slice(filename);
96        body
97    }
98
99    #[test]
100    fn parse_valid_file_desc() {
101        let fid = [0x11u8; 16];
102        let hf = [0x22u8; 16];
103        let h16 = [0x33u8; 16];
104        let body = make_file_desc_body(fid, hf, h16, 102400, b"testfile.bin\x00\x00\x00\x00");
105
106        let pkt = FileDescriptionPacket::parse(&body).unwrap();
107        assert_eq!(*pkt.file_id.as_bytes(), fid);
108        assert_eq!(pkt.hash_full, hf);
109        assert_eq!(pkt.hash_16k, h16);
110        assert_eq!(pkt.file_length, 102400);
111        assert_eq!(pkt.filename, "testfile.bin");
112    }
113
114    #[test]
115    fn parse_filename_no_null() {
116        let body = make_file_desc_body([0; 16], [0; 16], [0; 16], 0, b"noterm");
117        let pkt = FileDescriptionPacket::parse(&body).unwrap();
118        assert_eq!(pkt.filename, "noterm");
119    }
120
121    #[test]
122    fn reject_empty_filename() {
123        let body = make_file_desc_body([0; 16], [0; 16], [0; 16], 0, b"\x00\x00\x00\x00");
124        let err = FileDescriptionPacket::parse(&body).unwrap_err();
125        assert!(matches!(err, Par2Error::InvalidFileDescPacket { .. }));
126    }
127
128    #[test]
129    fn reject_too_short() {
130        let body = [0u8; 40];
131        let err = FileDescriptionPacket::parse(&body).unwrap_err();
132        assert!(matches!(err, Par2Error::InvalidFileDescPacket { .. }));
133    }
134
135    #[test]
136    fn reject_body_without_filename() {
137        // Exactly 56 bytes: no filename at all
138        let body = make_file_desc_body([0; 16], [0; 16], [0; 16], 42, b"");
139        let err = FileDescriptionPacket::parse(&body).unwrap_err();
140        assert!(matches!(err, Par2Error::InvalidFileDescPacket { .. }));
141    }
142
143    #[test]
144    fn translates_par2_paths_without_flattening() {
145        assert_eq!(
146            translate_par2_name_to_relative("../../etc/passwd").unwrap(),
147            "%2E%2E/%2E%2E/etc/passwd"
148        );
149        assert_eq!(
150            translate_par2_name_to_relative("/etc/passwd").unwrap(),
151            "%2F/etc/passwd"
152        );
153        assert_eq!(
154            translate_par2_name_to_relative("subdir/file.bin").unwrap(),
155            "subdir/file.bin"
156        );
157        assert_eq!(
158            translate_par2_name_to_relative("C:\\Users\\evil\\file.exe").unwrap(),
159            format!(
160                "C{}Users/evil/file.exe",
161                if cfg!(windows) { "%3A/" } else { ":/" }
162            )
163        );
164        assert_eq!(
165            translate_par2_name_to_relative("normal.par2").unwrap(),
166            "normal.par2"
167        );
168    }
169
170    #[test]
171    fn parse_path_traversal_filename() {
172        let body = make_file_desc_body([0; 16], [0; 16], [0; 16], 100, b"../../etc/passwd\x00");
173        let pkt = FileDescriptionPacket::parse(&body).unwrap();
174        assert_eq!(pkt.par2_name, "../../etc/passwd");
175        assert_eq!(pkt.filename, "%2E%2E/%2E%2E/etc/passwd");
176    }
177}