par2_rs/packet/
file_desc.rs1use 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#[derive(Debug, Clone)]
10pub struct FileDescriptionPacket {
11 pub file_id: FileId,
13 pub hash_full: [u8; 16],
15 pub hash_16k: [u8; 16],
17 pub file_length: u64,
19 pub filename: String,
21 pub par2_name: String,
23}
24
25impl FileDescriptionPacket {
26 pub fn parse(body: &[u8]) -> Result<Self> {
28 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 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 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}