Skip to main content

steamkit/types/
depot_manifest.rs

1//! Depot manifests: the list of files (and their chunks) in a depot.
2
3use std::cmp::Ordering;
4use std::collections::HashSet;
5use std::io::{Read, Write};
6use std::path::{Path, MAIN_SEPARATOR};
7use std::time::UNIX_EPOCH;
8
9use aes::cipher::block_padding::Pkcs7;
10use aes::cipher::BlockDecryptMut;
11use aes::cipher::KeyInit;
12use aes::Aes256;
13use base64::Engine;
14use cbc::cipher::KeyIvInit;
15use prost::Message;
16use sha1::{Digest, Sha1};
17use steamkit_protos::steam::content_manifest_payload::file_mapping::ChunkData as ProtoChunkData;
18use steamkit_protos::steam::content_manifest_payload::FileMapping as ProtoFileMapping;
19use steamkit_protos::steam::{ContentManifestMetadata, ContentManifestPayload, ContentManifestSignature};
20
21use crate::error::SteamError;
22use crate::types::manifest::{
23    date_time_from_unix_time, date_time_to_unix_time, ByteReader, Steam3Manifest,
24};
25
26/// The directory separator of the current platform.
27const DIRECTORY_SEPARATOR: char = MAIN_SEPARATOR;
28
29/// The directory separator of the current platform, as a string.
30const DIRECTORY_SEPARATOR_STR: &str = if MAIN_SEPARATOR == '\\' { "\\" } else { "/" };
31
32/// The separator Valve uses in depot manifests: the opposite of the platform one.
33const ALT_DIR_CHAR: char = if MAIN_SEPARATOR == '\\' { '/' } else { '\\' };
34
35/// The separator Valve uses in depot manifests, as a string.
36const ALT_DIR_CHAR_STR: &str = if MAIN_SEPARATOR == '\\' { "/" } else { "\\" };
37
38/// The magic value that introduces a protobuf payload section.
39const PROTOBUF_PAYLOAD_MAGIC: u32 = 0x71F6_17D0;
40/// The magic value that introduces a protobuf metadata section.
41const PROTOBUF_METADATA_MAGIC: u32 = 0x1F48_12BE;
42/// The magic value that introduces a protobuf signature section.
43const PROTOBUF_SIGNATURE_MAGIC: u32 = 0x1B81_B817;
44/// The magic value that marks the end of the manifest.
45const PROTOBUF_ENDOFMANIFEST_MAGIC: u32 = 0x32C4_15AB;
46
47/// A single chunk of a file.
48#[derive(Clone, Debug, Default, PartialEq, Eq)]
49pub struct ChunkData {
50    /// The SHA-1 chunk id.
51    pub chunk_id: Option<Vec<u8>>,
52    /// The expected Adler32 checksum.
53    pub checksum: u32,
54    /// The offset of the chunk in the file.
55    pub offset: u64,
56    /// The compressed length.
57    pub compressed_length: u32,
58    /// The uncompressed length.
59    pub uncompressed_length: u32,
60}
61
62impl ChunkData {
63    /// Creates an empty chunk.
64    pub fn new() -> ChunkData {
65        ChunkData::default()
66    }
67
68    /// Creates a chunk with the given values.
69    pub fn with_values(
70        id: Vec<u8>,
71        checksum: u32,
72        offset: u64,
73        comp_length: u32,
74        uncomp_length: u32,
75    ) -> ChunkData {
76        ChunkData {
77            chunk_id: Some(id),
78            checksum,
79            offset,
80            compressed_length: comp_length,
81            uncompressed_length: uncomp_length,
82        }
83    }
84}
85
86/// A single file in a manifest.
87#[derive(Clone, Debug, Default, PartialEq, Eq)]
88pub struct FileData {
89    /// The file name.
90    pub file_name: String,
91    /// SHA-1 hash of the file name.
92    pub file_name_hash: Vec<u8>,
93    /// The chunks the file is made of.
94    pub chunks: Vec<ChunkData>,
95    /// The `EDepotFileFlag` flags, as raw bits since a file can have several.
96    pub flags: u32,
97    /// The total size of the file.
98    pub total_size: u64,
99    /// SHA-1 hash of the file.
100    pub file_hash: Vec<u8>,
101    /// The symlink target, if it's a symlink.
102    pub link_target: Option<String>,
103}
104
105impl FileData {
106    /// Creates an empty file entry.
107    pub fn new() -> FileData {
108        FileData::default()
109    }
110
111    /// Creates a file entry with the given values.
112    pub fn with_values(
113        filename: &str,
114        filename_hash: Vec<u8>,
115        flags: u32,
116        size: u64,
117        hash: Vec<u8>,
118        link_target: &str,
119        encrypted: bool,
120        num_chunks: usize,
121    ) -> FileData {
122        let file_name = if encrypted {
123            filename.to_string()
124        } else {
125            filename.replace(ALT_DIR_CHAR_STR, DIRECTORY_SEPARATOR_STR)
126        };
127
128        FileData {
129            file_name,
130            file_name_hash: filename_hash,
131            flags,
132            total_size: size,
133            file_hash: hash,
134            chunks: Vec::with_capacity(num_chunks),
135            link_target: Some(link_target.to_string()),
136        }
137    }
138}
139
140/// A Steam3 depot manifest, from the CDN or a `Steam/depotcache/` file.
141#[derive(Clone, Debug, PartialEq, Eq)]
142pub struct DepotManifest {
143    files: Option<Vec<FileData>>,
144    filenames_encrypted: bool,
145    depot_id: u32,
146    manifest_gid: u64,
147    creation_time: std::time::SystemTime,
148    total_uncompressed_size: u64,
149    total_compressed_size: u64,
150    encrypted_crc: u32,
151}
152
153impl Default for DepotManifest {
154    fn default() -> DepotManifest {
155        DepotManifest {
156            files: None,
157            filenames_encrypted: false,
158            depot_id: 0,
159            manifest_gid: 0,
160            creation_time: UNIX_EPOCH,
161            total_uncompressed_size: 0,
162            total_compressed_size: 0,
163            encrypted_crc: 0,
164        }
165    }
166}
167
168impl DepotManifest {
169    /// Creates an empty manifest.
170    pub fn new() -> DepotManifest {
171        DepotManifest::default()
172    }
173
174    /// The files in this manifest.
175    pub fn files(&self) -> Option<&[FileData]> {
176        self.files.as_deref()
177    }
178
179    /// Sets the files in this manifest.
180    pub fn set_files(&mut self, files: Option<Vec<FileData>>) {
181        self.files = files;
182    }
183
184    /// Whether the file names are encrypted.
185    pub fn filenames_encrypted(&self) -> bool {
186        self.filenames_encrypted
187    }
188
189    /// Sets whether the file names are encrypted.
190    pub fn set_filenames_encrypted(&mut self, filenames_encrypted: bool) {
191        self.filenames_encrypted = filenames_encrypted;
192    }
193
194    /// The depot id.
195    pub fn depot_id(&self) -> u32 {
196        self.depot_id
197    }
198
199    /// Sets the depot id.
200    pub fn set_depot_id(&mut self, depot_id: u32) {
201        self.depot_id = depot_id;
202    }
203
204    /// The manifest id.
205    pub fn manifest_gid(&self) -> u64 {
206        self.manifest_gid
207    }
208
209    /// Sets the manifest id.
210    pub fn set_manifest_gid(&mut self, manifest_gid: u64) {
211        self.manifest_gid = manifest_gid;
212    }
213
214    /// When the manifest was created.
215    pub fn creation_time(&self) -> std::time::SystemTime {
216        self.creation_time
217    }
218
219    /// Sets when the manifest was created.
220    pub fn set_creation_time(&mut self, creation_time: std::time::SystemTime) {
221        self.creation_time = creation_time;
222    }
223
224    /// The total uncompressed size of all files.
225    pub fn total_uncompressed_size(&self) -> u64 {
226        self.total_uncompressed_size
227    }
228
229    /// Sets the total uncompressed size of all files.
230    pub fn set_total_uncompressed_size(&mut self, total_uncompressed_size: u64) {
231        self.total_uncompressed_size = total_uncompressed_size;
232    }
233
234    /// The total compressed size of all files.
235    pub fn total_compressed_size(&self) -> u64 {
236        self.total_compressed_size
237    }
238
239    /// Sets the total compressed size of all files.
240    pub fn set_total_compressed_size(&mut self, total_compressed_size: u64) {
241        self.total_compressed_size = total_compressed_size;
242    }
243
244    /// The CRC-32 of the encrypted manifest payload.
245    pub fn encrypted_crc(&self) -> u32 {
246        self.encrypted_crc
247    }
248
249    /// Sets the CRC-32 of the encrypted manifest payload.
250    pub fn set_encrypted_crc(&mut self, encrypted_crc: u32) {
251        self.encrypted_crc = encrypted_crc;
252    }
253
254    /// Parses a manifest from bytes.
255    pub fn deserialize(data: &[u8]) -> Result<DepotManifest, SteamError> {
256        let mut reader = ByteReader::new(data);
257        DepotManifest::internal_deserialize(&mut reader)
258    }
259
260    /// Parses a manifest from a stream.
261    pub fn deserialize_from_stream(stream: &mut dyn Read) -> Result<DepotManifest, SteamError> {
262        let mut data = Vec::new();
263        stream.read_to_end(&mut data)?;
264        DepotManifest::deserialize(&data)
265    }
266
267    /// Loads a binary manifest from a file.
268    pub async fn load_from_file(
269        filename: impl AsRef<Path>,
270    ) -> Result<Option<DepotManifest>, SteamError> {
271        match tokio::fs::read(filename).await {
272            Ok(data) => Ok(Some(DepotManifest::deserialize(&data)?)),
273            Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
274            Err(err) => Err(err.into()),
275        }
276    }
277
278    /// Serializes the manifest to a file.
279    pub async fn save_to_file(&self, filename: impl AsRef<Path>) -> Result<(), SteamError> {
280        let mut buffer = Vec::new();
281        self.serialize(&mut buffer)?;
282        tokio::fs::write(filename, buffer).await?;
283        Ok(())
284    }
285
286    /// Serializes the manifest into `output`.
287    pub fn serialize(&self, output: &mut dyn Write) -> Result<(), SteamError> {
288        let mut payload = ContentManifestPayload::default();
289        let mut unique_chunks: HashSet<Vec<u8>> = HashSet::new();
290
291        for file in self.files.iter().flatten() {
292            let mut protofile = ProtoFileMapping::default();
293            protofile.size = Some(file.total_size);
294            protofile.flags = Some(file.flags);
295
296            if self.filenames_encrypted {
297                // Assume the name is unmodified.
298                protofile.filename = Some(file.file_name.clone());
299                protofile.sha_filename = Some(file.file_name_hash.clone());
300            } else {
301                let filename = file.file_name.replace('/', "\\");
302                protofile.filename = Some(filename.clone());
303                protofile.sha_filename = Some(
304                    Sha1::digest(filename.to_lowercase().as_bytes())
305                        .as_slice()
306                        .to_vec(),
307                );
308            }
309
310            protofile.sha_content = Some(file.file_hash.clone());
311
312            if let Some(link_target) = file.link_target.as_ref() {
313                if !link_target.trim().is_empty() {
314                    protofile.linktarget = Some(link_target.clone());
315                }
316            }
317
318            for chunk in &file.chunks {
319                let mut protochunk = ProtoChunkData::default();
320                protochunk.sha = chunk.chunk_id.clone();
321                protochunk.crc = Some(chunk.checksum);
322                protochunk.offset = Some(chunk.offset);
323                protochunk.cb_original = Some(chunk.uncompressed_length);
324                protochunk.cb_compressed = Some(chunk.compressed_length);
325
326                protofile.chunks.push(protochunk);
327
328                if let Some(chunk_id) = chunk.chunk_id.as_ref() {
329                    unique_chunks.insert(chunk_id.clone());
330                }
331            }
332
333            payload.mappings.push(protofile);
334        }
335
336        let mut metadata = ContentManifestMetadata::default();
337        metadata.depot_id = Some(self.depot_id);
338        metadata.gid_manifest = Some(self.manifest_gid);
339        metadata.creation_time = Some(date_time_to_unix_time(self.creation_time) as u32);
340        metadata.filenames_encrypted = Some(self.filenames_encrypted);
341        metadata.cb_disk_original = Some(self.total_uncompressed_size);
342        metadata.cb_disk_compressed = Some(self.total_compressed_size);
343        metadata.unique_chunks = Some(unique_chunks.len() as u32);
344
345        let payload_bytes = payload.encode_to_vec();
346
347        // The payload CRC covers the length-prefixed payload.
348        let mut crc_data = Vec::with_capacity(4 + payload_bytes.len());
349        crc_data.extend_from_slice(&(payload_bytes.len() as i32).to_le_bytes());
350        crc_data.extend_from_slice(&payload_bytes);
351        let crc32 = crc32fast::hash(&crc_data);
352
353        if self.filenames_encrypted {
354            metadata.crc_encrypted = Some(crc32);
355            metadata.crc_clear = Some(0);
356        } else {
357            metadata.crc_encrypted = Some(self.encrypted_crc);
358            metadata.crc_clear = Some(crc32);
359        }
360
361        let metadata_bytes = metadata.encode_to_vec();
362
363        // Protobuf payload
364        output.write_all(&PROTOBUF_PAYLOAD_MAGIC.to_le_bytes())?;
365        output.write_all(&(payload_bytes.len() as i32).to_le_bytes())?;
366        output.write_all(&payload_bytes)?;
367
368        // Protobuf metadata
369        output.write_all(&PROTOBUF_METADATA_MAGIC.to_le_bytes())?;
370        output.write_all(&(metadata_bytes.len() as i32).to_le_bytes())?;
371        output.write_all(&metadata_bytes)?;
372
373        // Empty signature section
374        output.write_all(&PROTOBUF_SIGNATURE_MAGIC.to_le_bytes())?;
375        output.write_all(&0i32.to_le_bytes())?;
376
377        // End marker
378        output.write_all(&PROTOBUF_ENDOFMANIFEST_MAGIC.to_le_bytes())?;
379
380        Ok(())
381    }
382
383    /// Tries to decrypt the file names with the given key. Returns `false` if
384    /// that fails.
385    pub fn decrypt_filenames(&mut self, encryption_key: &[u8]) -> bool {
386        if !self.filenames_encrypted {
387            return true;
388        }
389
390        if encryption_key.len() != 32 {
391            tracing::error!("Decrypt filenames used with non 32 byte key!");
392            return false;
393        }
394
395        {
396            let Some(files) = self.files.as_mut() else {
397                tracing::error!("Files was null when attempting to decrypt filenames.");
398                return false;
399            };
400
401            for file in files.iter_mut() {
402                let Some(name) = decrypt_name(&file.file_name, encryption_key) else {
403                    tracing::error!("Failed to decrypt the filename.");
404                    return false;
405                };
406
407                file.file_name = name;
408
409                if let Some(link_target) = file.link_target.as_ref() {
410                    if !link_target.is_empty() {
411                        let Some(link_name) = decrypt_name(link_target, encryption_key) else {
412                            tracing::error!("Failed to decrypt the filename.");
413                            return false;
414                        };
415
416                        file.link_target = Some(link_name);
417                    }
418                }
419            }
420
421            // Sort the files alphabetically, like Steam does.
422            files.sort_by(|left, right| {
423                cmp_ordinal_ignore_case(&left.file_name, &right.file_name)
424            });
425        }
426
427        self.filenames_encrypted = false;
428        true
429    }
430
431    /// Parses a manifest in either format.
432    fn internal_deserialize(reader: &mut ByteReader<'_>) -> Result<DepotManifest, SteamError> {
433        let mut payload: Option<ContentManifestPayload> = None;
434        let mut metadata: Option<ContentManifestMetadata> = None;
435        let mut signature: Option<ContentManifestSignature> = None;
436
437        loop {
438            let magic = reader.read_u32()?;
439
440            if magic == PROTOBUF_ENDOFMANIFEST_MAGIC {
441                break;
442            }
443
444            match magic {
445                Steam3Manifest::MAGIC => {
446                    let mut binary_manifest = Steam3Manifest::default();
447                    binary_manifest.deserialize(reader)?;
448
449                    let mut manifest = DepotManifest::new();
450                    manifest.parse_binary_manifest(&binary_manifest);
451
452                    let marker = reader.read_u32()?;
453                    if marker != magic {
454                        return Err(SteamError::Malformed(
455                            "Unable to find end of message marker for depot manifest".to_string(),
456                        ));
457                    }
458
459                    // Return early on purpose: a v4 manifest has no separate
460                    // sections, and `parse_binary_manifest` handles it.
461                    return Ok(manifest);
462                }
463                PROTOBUF_PAYLOAD_MAGIC => {
464                    let length = reader.read_u32()? as usize;
465                    let bytes = reader.read_bytes(length);
466                    payload = Some(ContentManifestPayload::decode(bytes.as_slice())?);
467                }
468                PROTOBUF_METADATA_MAGIC => {
469                    let length = reader.read_u32()? as usize;
470                    let bytes = reader.read_bytes(length);
471                    metadata = Some(ContentManifestMetadata::decode(bytes.as_slice())?);
472                }
473                PROTOBUF_SIGNATURE_MAGIC => {
474                    let length = reader.read_u32()? as usize;
475                    let bytes = reader.read_bytes(length);
476                    signature = Some(ContentManifestSignature::decode(bytes.as_slice())?);
477                }
478                other => {
479                    return Err(SteamError::Malformed(format!(
480                        "Unrecognized magic value {other:X} in depot manifest."
481                    )))
482                }
483            }
484        }
485
486        match (payload, metadata, signature) {
487            (Some(payload), Some(metadata), Some(_)) => {
488                let mut manifest = DepotManifest::new();
489                manifest.parse_protobuf_manifest_metadata(&metadata);
490                manifest.parse_protobuf_manifest_payload(&payload);
491                Ok(manifest)
492            }
493            _ => Err(SteamError::Malformed(
494                "Missing ContentManifest sections required for parsing depot manifest".to_string(),
495            )),
496        }
497    }
498
499    /// Fills this in from a v4 (binary) manifest.
500    fn parse_binary_manifest(&mut self, manifest: &Steam3Manifest) {
501        let filenames_encrypted = manifest.are_file_names_encrypted;
502        let mut files = Vec::with_capacity(manifest.mapping.len());
503
504        for file_mapping in &manifest.mapping {
505            let mut file_data = FileData::with_values(
506                file_mapping.file_name.as_deref().unwrap_or(""),
507                file_mapping.hash_file_name.clone().unwrap_or_default(),
508                file_mapping.flags,
509                file_mapping.total_size,
510                file_mapping.hash_content.clone().unwrap_or_default(),
511                "",
512                filenames_encrypted,
513                file_mapping.chunks.len(),
514            );
515
516            for chunk in &file_mapping.chunks {
517                file_data.chunks.push(ChunkData::with_values(
518                    chunk.chunk_gid.clone().unwrap_or_default(),
519                    chunk.checksum,
520                    chunk.offset,
521                    chunk.compressed_size,
522                    chunk.decompressed_size,
523                ));
524            }
525
526            files.push(file_data);
527        }
528
529        self.files = Some(files);
530        self.filenames_encrypted = filenames_encrypted;
531        self.depot_id = manifest.depot_id;
532        self.manifest_gid = manifest.manifest_gid;
533        self.creation_time = manifest.creation_time;
534        self.total_uncompressed_size = manifest.total_uncompressed_size;
535        self.total_compressed_size = manifest.total_compressed_size;
536        self.encrypted_crc = manifest.encrypted_crc;
537    }
538
539    /// Fills this in from a protobuf manifest payload.
540    fn parse_protobuf_manifest_payload(&mut self, payload: &ContentManifestPayload) {
541        let mut files = Vec::with_capacity(payload.mappings.len());
542
543        for file_mapping in &payload.mappings {
544            let mut file_data = FileData::with_values(
545                file_mapping.filename.as_deref().unwrap_or(""),
546                file_mapping.sha_filename.clone().unwrap_or_default(),
547                file_mapping.flags.unwrap_or(0),
548                file_mapping.size.unwrap_or(0),
549                file_mapping.sha_content.clone().unwrap_or_default(),
550                file_mapping.linktarget.as_deref().unwrap_or(""),
551                self.filenames_encrypted,
552                file_mapping.chunks.len(),
553            );
554
555            for chunk in &file_mapping.chunks {
556                file_data.chunks.push(ChunkData::with_values(
557                    chunk.sha.clone().unwrap_or_default(),
558                    chunk.crc.unwrap_or(0),
559                    chunk.offset.unwrap_or(0),
560                    chunk.cb_compressed.unwrap_or(0),
561                    chunk.cb_original.unwrap_or(0),
562                ));
563            }
564
565            files.push(file_data);
566        }
567
568        self.files = Some(files);
569    }
570
571    /// Fills this in from protobuf manifest metadata.
572    fn parse_protobuf_manifest_metadata(&mut self, metadata: &ContentManifestMetadata) {
573        self.filenames_encrypted = metadata.filenames_encrypted.unwrap_or(false);
574        self.depot_id = metadata.depot_id.unwrap_or(0);
575        self.manifest_gid = metadata.gid_manifest.unwrap_or(0);
576        self.creation_time = date_time_from_unix_time(metadata.creation_time.unwrap_or(0) as u64);
577        self.total_uncompressed_size = metadata.cb_disk_original.unwrap_or(0);
578        self.total_compressed_size = metadata.cb_disk_compressed.unwrap_or(0);
579        self.encrypted_crc = metadata.crc_encrypted.unwrap_or(0);
580    }
581}
582
583/// Decrypts a single base64-encoded, encrypted file name.
584fn decrypt_name(name: &str, encryption_key: &[u8]) -> Option<String> {
585    use base64::prelude::BASE64_STANDARD;
586
587    // The encrypted names have a trailing newline, which strict base64 decoding
588    // rejects, so drop whitespace first.
589    let encoded: String = name.chars().filter(|c| !c.is_whitespace()).collect();
590    let decoded = BASE64_STANDARD.decode(encoded.as_bytes()).ok()?;
591
592    // The first block is the encrypted IV, the rest is the encrypted name.
593    if decoded.len() < 16 {
594        return None;
595    }
596
597    let cipher = Aes256::new_from_slice(encryption_key).ok()?;
598
599    let mut iv = aes::cipher::Block::<Aes256>::clone_from_slice(&decoded[..16]);
600    <Aes256 as aes::cipher::BlockDecrypt>::decrypt_block(&cipher, &mut iv);
601
602    let decryptor = cbc::Decryptor::<Aes256>::new_from_slices(encryption_key, &iv).ok()?;
603
604    let mut buffer = decoded[16..].to_vec();
605    let plaintext_len = decryptor.decrypt_padded_mut::<Pkcs7>(&mut buffer).ok()?.len();
606
607    // Trim the trailing null byte of the plaintext.
608    let mut filename_length = plaintext_len;
609    if filename_length > 0 && buffer[filename_length - 1] == 0 {
610        filename_length -= 1;
611    }
612
613    // ASCII is a subset of UTF-8, so replacing the raw bytes is safe.
614    for byte in buffer.iter_mut() {
615        if *byte == ALT_DIR_CHAR as u8 {
616            *byte = DIRECTORY_SEPARATOR as u8;
617        }
618    }
619
620    Some(String::from_utf8_lossy(&buffer[..filename_length]).into_owned())
621}
622
623/// Ordinal, case-insensitive string comparison.
624fn cmp_ordinal_ignore_case(left: &str, right: &str) -> Ordering {
625    let mut left_chars = left
626        .chars()
627        .map(|ch| ch.to_uppercase().next().unwrap_or(ch));
628    let mut right_chars = right
629        .chars()
630        .map(|ch| ch.to_uppercase().next().unwrap_or(ch));
631
632    loop {
633        match (left_chars.next(), right_chars.next()) {
634            (None, None) => return Ordering::Equal,
635            (None, Some(_)) => return Ordering::Less,
636            (Some(_), None) => return Ordering::Greater,
637            (Some(left_char), Some(right_char)) => match left_char.cmp(&right_char) {
638                Ordering::Equal => continue,
639                other => return other,
640            },
641        }
642    }
643}