Skip to main content

opentele2_core/
tdf.rs

1//! The `TDF$` container that wraps every Telegram Desktop `tdata` file.
2//!
3//! Layout:
4//!
5//! ```text
6//! b"TDF$" | version: u32 LE | payload | md5(payload | len(payload): u32 LE | version: u32 LE | b"TDF$")
7//! ```
8//!
9//! On disk a file named `name` is stored as `name` plus one of the suffixes
10//! `s`, `1`, `0`. [`read`] tries them in that order and [`write()`] always
11//! writes the `s` variant.
12
13use std::fs;
14use std::path::Path;
15
16use md5::{Digest, Md5};
17
18use crate::{Error, MagicContext, Result};
19
20/// The four bytes every `TDF$` file starts with.
21pub const MAGIC: [u8; 4] = *b"TDF$";
22
23/// File name suffixes [`read`] tries, in order.
24pub const READ_SUFFIXES: [&str; 3] = ["s", "1", "0"];
25
26/// File name suffix [`write()`] uses.
27pub const WRITE_SUFFIX: &str = "s";
28
29const HEADER_LEN: usize = MAGIC.len() + 4;
30const CHECKSUM_LEN: usize = 16;
31
32/// A decoded `TDF$` file: the app version that wrote it and its payload.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct TdfFile {
35    /// Telegram Desktop version number stored after the magic.
36    pub version: u32,
37    /// Bytes between the header and the checksum.
38    pub payload: Vec<u8>,
39}
40
41/// MD5 over the payload and trailer fields, as Telegram Desktop computes it.
42fn checksum(version: u32, payload: &[u8]) -> [u8; CHECKSUM_LEN] {
43    // tdesktop hashes the length as a 32-bit int; widen first so the low
44    // four bytes are taken on every platform without a truncating cast.
45    let len = (payload.len() as u64).to_le_bytes();
46    let mut hasher = Md5::new();
47    hasher.update(payload);
48    hasher.update(&len[..4]);
49    hasher.update(version.to_le_bytes());
50    hasher.update(MAGIC);
51    let mut digest = [0; CHECKSUM_LEN];
52    digest.copy_from_slice(&hasher.finalize());
53    digest
54}
55
56impl TdfFile {
57    /// Decodes the bytes of a whole `TDF$` file and checks its MD5.
58    ///
59    /// # Errors
60    ///
61    /// - [`Error::QDataStreamFailed`] when the input is too short to hold the
62    ///   magic, the version, and the checksum.
63    /// - [`Error::InvalidMagic`] when it does not start with `TDF$`.
64    /// - [`Error::InvalidCheckSum`] when the stored MD5 does not match.
65    ///
66    /// The errors name no file; [`read`] fills in the name it read.
67    pub fn parse(data: &[u8]) -> Result<Self> {
68        let magic = data
69            .get(..MAGIC.len())
70            .ok_or(Error::QDataStreamFailed { context: None })?;
71        if magic != MAGIC {
72            return Err(Error::InvalidMagic {
73                context: MagicContext::File {
74                    file: String::new(),
75                    magic: magic.to_vec(),
76                },
77            });
78        }
79        if data.len() < HEADER_LEN + CHECKSUM_LEN {
80            return Err(Error::QDataStreamFailed { context: None });
81        }
82        let (header, rest) = data.split_at(HEADER_LEN);
83        let (payload, stored) = rest.split_at(rest.len() - CHECKSUM_LEN);
84        let mut version = [0; 4];
85        version.copy_from_slice(&header[MAGIC.len()..]);
86        let version = u32::from_le_bytes(version);
87        let computed = checksum(version, payload);
88        if computed != stored {
89            return Err(Error::InvalidCheckSum {
90                file: String::new(),
91                computed,
92            });
93        }
94        Ok(Self {
95            version,
96            payload: payload.to_vec(),
97        })
98    }
99
100    /// Encodes the file bytes: header, payload, and MD5 checksum.
101    #[must_use]
102    pub fn encode(&self) -> Vec<u8> {
103        let mut out = Vec::with_capacity(HEADER_LEN + self.payload.len() + CHECKSUM_LEN);
104        out.extend_from_slice(&MAGIC);
105        out.extend_from_slice(&self.version.to_le_bytes());
106        out.extend_from_slice(&self.payload);
107        out.extend_from_slice(&checksum(self.version, &self.payload));
108        out
109    }
110}
111
112/// Reads `name` from `dir`, trying the suffixes of [`READ_SUFFIXES`] in order.
113///
114/// A suffix whose file cannot be opened or fails to parse is skipped and the
115/// next one is tried; the first that parses wins.
116///
117/// # Errors
118///
119/// When no suffix yields a valid file: the error of the last file that exists
120/// but failed to parse ([`Error::InvalidMagic`], [`Error::InvalidCheckSum`],
121/// or [`Error::QDataStreamFailed`]), else [`Error::FileNotFound`]. The
122/// magic, checksum and not-found errors carry `name`, so their messages name
123/// the file.
124pub fn read(dir: impl AsRef<Path>, name: &str) -> Result<TdfFile> {
125    let dir = dir.as_ref();
126    let mut failure = None;
127    for suffix in READ_SUFFIXES {
128        // Any file that cannot be opened or read counts as absent.
129        let Ok(data) = fs::read(dir.join(format!("{name}{suffix}"))) else {
130            continue;
131        };
132        match TdfFile::parse(&data) {
133            Ok(file) => return Ok(file),
134            Err(error) => failure = Some(named(error, name)),
135        }
136    }
137    Err(failure.unwrap_or_else(|| Error::FileNotFound {
138        file: name.to_owned(),
139    }))
140}
141
142/// `error` with the file name `name` filled in.
143fn named(error: Error, name: &str) -> Error {
144    match error {
145        Error::InvalidMagic {
146            context: MagicContext::File { magic, .. },
147        } => Error::InvalidMagic {
148            context: MagicContext::File {
149                file: name.to_owned(),
150                magic,
151            },
152        },
153        Error::InvalidCheckSum { computed, .. } => Error::InvalidCheckSum {
154            file: name.to_owned(),
155            computed,
156        },
157        other => other,
158    }
159}
160
161/// Writes `file` to `dir` as `name` plus [`WRITE_SUFFIX`], creating `dir`
162/// when it does not exist.
163///
164/// # Errors
165///
166/// [`Error::Io`] when the directory cannot be created or the file written.
167pub fn write(dir: impl AsRef<Path>, name: &str, file: &TdfFile) -> Result<()> {
168    let dir = dir.as_ref();
169    fs::create_dir_all(dir)?;
170    fs::write(dir.join(format!("{name}{WRITE_SUFFIX}")), file.encode())?;
171    Ok(())
172}