1use crate::{protobuf as p, wire::base_request, AccountState};
5use anyhow::{ensure, Result};
6use base64::{engine::general_purpose::STANDARD, Engine};
7use serde::{Deserialize, Serialize};
8use std::fmt;
9use zeroize::Zeroize;
10
11pub const MAX_RESERVED_FILE_BYTES: usize = crate::media::MAX_LARGE_FILE_BYTES;
12const MAX_FILE_UPLOAD_TOKEN_BYTES: usize = 16 * 1024;
13
14#[derive(Clone, Serialize, Deserialize)]
15#[serde(rename_all = "camelCase")]
16pub struct FileUploadReservation {
17 pub file_upload_token: String,
18 pub overwrite_newmsgid: u64,
19}
20impl Drop for FileUploadReservation {
21 fn drop(&mut self) {
22 self.file_upload_token.zeroize();
23 self.overwrite_newmsgid.zeroize();
24 }
25}
26impl fmt::Debug for FileUploadReservation {
27 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
28 f.debug_struct("FileUploadReservation")
29 .field("receipt", &"[redacted]")
30 .finish()
31 }
32}
33impl FileUploadReservation {
34 pub fn validate(&self) -> Result<()> {
35 ensure!(
36 self.overwrite_newmsgid != 0
37 && self.file_upload_token.len() <= MAX_FILE_UPLOAD_TOKEN_BYTES,
38 "Invalid file upload reservation"
39 );
40 let encoded = self
41 .file_upload_token
42 .strip_prefix("v1_")
43 .ok_or_else(|| anyhow::anyhow!("Unsupported file upload token"))?;
44 ensure!(!encoded.is_empty(), "Empty file upload token");
45 let mut decoded = STANDARD.decode(encoded)?;
46 let nonempty = !decoded.is_empty();
47 decoded.zeroize();
48 ensure!(nonempty, "Empty file upload token");
49 Ok(())
50 }
51}
52
53pub(crate) fn file_metadata<'a>(
54 file_name: &'a str,
55 plaintext_bytes: usize,
56 md5: &str,
57) -> Result<(&'a str, String)> {
58 ensure!(
59 !file_name.is_empty()
60 && file_name.len() <= 255
61 && !file_name.chars().any(char::is_control)
62 && !matches!(file_name, "." | "..")
63 && !file_name.contains(['/', '\\']),
64 "Expected a single filename component"
65 );
66 ensure!(
67 plaintext_bytes > 0 && plaintext_bytes <= MAX_RESERVED_FILE_BYTES,
68 "Invalid reserved file size"
69 );
70 ensure!(
71 md5.len() == 32 && md5.bytes().all(|b| b.is_ascii_hexdigit()),
72 "Invalid file MD5"
73 );
74 let extension = file_name
75 .rsplit_once('.')
76 .filter(|(base, _)| !base.is_empty())
77 .map(|(_, extension)| extension)
78 .unwrap_or("");
79 Ok((extension, md5.to_ascii_lowercase()))
80}
81
82pub fn file_upload_request(
83 state: &AccountState,
84 destination: &str,
85 progress_client_id: &str,
86 file_name: &str,
87 plaintext_bytes: usize,
88 md5: &str,
89) -> Result<Vec<u8>> {
90 for (value, max) in [(destination, 256), (progress_client_id, 1024)] {
91 ensure!(
92 !value.is_empty() && value.len() <= max && !value.chars().any(char::is_control),
93 "Invalid file upload destination or progress identity"
94 );
95 }
96 let (extension, md5) = file_metadata(file_name, plaintext_bytes, md5)?;
97 let info = p::message(&[
98 p::bytes(1, file_name.as_bytes()),
99 p::bytes(2, extension.as_bytes()),
100 p::bytes(3, md5.as_bytes()),
101 p::uint(4, plaintext_bytes as u64),
102 ])?;
103 p::message(&[
104 p::bytes(1, &base_request(&state.device, state.uin, 0)?),
105 p::bytes(2, destination.as_bytes()),
106 p::bytes(3, progress_client_id.as_bytes()),
107 p::uint(4, 1),
108 p::bytes(5, &info),
109 p::uint(7, 0),
110 p::uint(12, 0),
111 ])
112}
113
114pub fn decode_file_upload_reservation(input: &[u8]) -> Result<FileUploadReservation> {
117 ensure!(
118 input.len() <= 64 * 1024,
119 "File upload reservation response too large"
120 );
121 let fields = p::decode(input)?;
122 let base = p::decode(p::data(&fields, 1)?)?;
123 let code = p::scalar(&base, 1)?;
124 ensure!(
125 code == 0,
126 "File upload reservation returned {}",
127 code as u32 as i32
128 );
129 let receipt = FileUploadReservation {
130 file_upload_token: std::str::from_utf8(p::data(&fields, 2)?)?.to_owned(),
131 overwrite_newmsgid: p::scalar(&fields, 3)?,
132 };
133 receipt.validate()?;
134 Ok(receipt)
135}
136
137#[cfg(test)]
138mod tests {
139 use super::*;
140
141 fn reply(token: &[u8], id: u64) -> Vec<u8> {
142 p::message(&[
143 p::bytes(1, &p::int32(1, 0)),
144 p::bytes(2, token),
145 p::uint(3, id),
146 ])
147 .unwrap()
148 }
149
150 #[test]
151 fn receipt_roundtrip_redacts_and_retains_full_id() {
152 let reservation =
153 decode_file_upload_reservation(&reply(b"v1_c3ludGhldGlj", u64::MAX)).unwrap();
154 assert_eq!(reservation.overwrite_newmsgid, u64::MAX);
155 let stored = serde_json::to_vec(&reservation).unwrap();
156 let restored: FileUploadReservation = serde_json::from_slice(&stored).unwrap();
157 restored.validate().unwrap();
158 assert_eq!(restored.file_upload_token, reservation.file_upload_token);
159 assert!(!format!("{restored:?}").contains("c3ludGhldGlj"));
160 assert!(!format!("{restored:?}").contains(&u64::MAX.to_string()));
161 }
162
163 #[test]
164 fn receipt_rejects_ambiguous_missing_invalid_and_rejected_fields() {
165 for input in [
166 reply(b"v1_c3ludGhldGlj", 0),
167 reply(b"v2_c3ludGhldGlj", 1),
168 reply(b"v1_", 1),
169 reply(b"v1_!!!", 1),
170 reply(&vec![b'a'; MAX_FILE_UPLOAD_TOKEN_BYTES + 1], 1),
171 p::message(&[p::bytes(1, &p::int32(1, -13))]).unwrap(),
172 p::message(&[
173 p::bytes(1, &p::uint(1, 1 << 32)),
174 p::bytes(2, b"v1_c3ludGhldGlj"),
175 p::uint(3, 1),
176 ])
177 .unwrap(),
178 p::message(&[p::bytes(1, &p::int32(1, 0)), p::uint(3, 1)]).unwrap(),
179 [reply(b"v1_c3ludGhldGlj", 1), p::uint(3, 2)].concat(),
180 [reply(b"v1_c3ludGhldGlj", 1), p::bytes(2, b"v1_YQ==")].concat(),
181 ] {
182 assert!(decode_file_upload_reservation(&input).is_err());
183 }
184 }
185
186 #[test]
187 fn metadata_bounds_and_extension() {
188 assert_eq!(
189 file_metadata("sample.TXT", MAX_RESERVED_FILE_BYTES, &"A".repeat(32)).unwrap(),
190 ("TXT", "a".repeat(32))
191 );
192 for name in ["", ".", "..", "a/b", "a\\b", "a\n.txt"] {
193 assert!(file_metadata(name, 1, &"0".repeat(32)).is_err());
194 }
195 for size in [0, MAX_RESERVED_FILE_BYTES + 1] {
196 assert!(file_metadata("sample.txt", size, &"0".repeat(32)).is_err());
197 }
198 assert!(file_metadata("sample.txt", 1, &"z".repeat(32)).is_err());
199 assert!(file_metadata(&"a".repeat(256), 1, &"0".repeat(32)).is_err());
200 }
201}