hermes-tdata 0.1.1

Pure Rust parser for Telegram Desktop's tdata storage format. Decrypts local storage and extracts auth keys without Qt/C++ dependencies.
Documentation
//! File I/O utilities for reading tdata files
//!
//! Handles reading and parsing of tdata file descriptors with MD5 verification.

use std::fs;
use std::path::{Path, PathBuf};

use crate::{Error, Result};

/// Magic bytes at the start of tdata files
const TDATA_MAGIC: [u8; 4] = [0x54, 0x44, 0x46, 0x24]; // "TDF$"

/// File descriptor for reading tdata files
#[derive(Debug)]
pub struct FileDescriptor {
    pub version: u32,
    pub data: Vec<u8>,
}

/// Read a tdata file
pub fn read_file(name: &str, base_path: &Path) -> Result<FileDescriptor> {
    let path = base_path.join(name);
    let path_s = base_path.join(format!("{name}s"));

    tracing::debug!("Trying to read file: {:?}", path);

    // Try main file first, then backup (s suffix)
    // Use is_file() to skip directories
    let file_data = if path.is_file() {
        tracing::debug!("Reading main file: {:?}", path);
        fs::read(&path)?
    } else if path_s.is_file() {
        tracing::debug!("Reading backup file: {:?}", path_s);
        fs::read(&path_s)?
    } else {
        return Err(Error::FileNotFound { file: name.to_owned(), folder: base_path.to_path_buf() });
    };

    tracing::debug!("Read {} bytes", file_data.len());
    parse_file_descriptor(&file_data)
}

/// Parse a file descriptor from raw bytes
///
/// File format:
/// - bytes[0..4]: magic "TDF$"
/// - bytes[4..8]: version (little endian)
/// - bytes[8..len-16]: data payload
/// - bytes[len-16..len]: MD5 checksum of (data + dataSize + version + magic)
fn parse_file_descriptor(data: &[u8]) -> Result<FileDescriptor> {
    if data.len() < 8 + 16 {
        return Err(Error::invalid_format("file too short"));
    }

    // Check magic
    if data[0..4] != TDATA_MAGIC {
        return Err(Error::invalid_format("invalid file magic"));
    }

    // Read version (little endian)
    let version = u32::from_le_bytes([data[4], data[5], data[6], data[7]]);

    // Data is between header and MD5
    let data_size = data.len() - 8 - 16;
    let payload = &data[8..8 + data_size];
    let file_md5 = &data[data.len() - 16..];

    // Verify MD5: data + dataSize(LE) + version(LE) + magic
    use md5::{Digest, Md5};
    let mut hasher = Md5::new();
    hasher.update(payload);
    hasher.update((data_size as u32).to_le_bytes());
    hasher.update(version.to_le_bytes());
    hasher.update(TDATA_MAGIC);
    let computed_md5: [u8; 16] = hasher.finalize().into();

    tracing::debug!("MD5 check: file={:02x?}, computed={:02x?}", file_md5, computed_md5);

    if file_md5 != computed_md5.as_slice() {
        return Err(Error::ChecksumMismatch);
    }

    Ok(FileDescriptor { version, data: payload.to_vec() })
}

/// Get the absolute path, expanding ~ if needed
pub fn get_absolute_path(path: &str) -> PathBuf {
    if let Some(rest) = path.strip_prefix("~/")
        && let Some(home) = dirs::home_dir()
    {
        return home.join(rest);
    }
    if path == "~"
        && let Some(home) = dirs::home_dir()
    {
        return home;
    }
    PathBuf::from(path)
}

/// Get default tdata path for the current OS
pub fn get_default_tdata_path() -> Option<PathBuf> {
    #[cfg(target_os = "linux")]
    {
        dirs::home_dir().map(|h| h.join(".local/share/TelegramDesktop/tdata"))
    }

    #[cfg(target_os = "macos")]
    {
        dirs::home_dir().map(|h| h.join("Library/Application Support/Telegram Desktop/tdata"))
    }

    #[cfg(target_os = "windows")]
    {
        dirs::data_local_dir().map(|d| d.join("Telegram Desktop/tdata"))
    }

    #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
    {
        None
    }
}