Skip to main content

wechat_protocol/
file_upload.rs

1//! File-upload message reservation. This authenticated request can affect the
2//! account's message state; callers must persist its receipt and never retry an
3//! uncertain response automatically.
4use 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
114/// Decode the receipt from one authenticated CGI6691 response. This response
115/// has no client-ID echo; the caller must retain its association with the intent.
116pub 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}