Skip to main content

steamkit/steam/cdn/
client.rs

1//! Downloading game content (manifests and chunks) from Steam's CDN.
2
3use std::io::Read;
4use std::sync::atomic::{AtomicU64, Ordering};
5use std::time::Duration;
6
7use reqwest::Url;
8
9use crate::error::SteamError;
10use crate::steam::steam_client::configuration::steam_configuration::HttpClientPurpose;
11use crate::steam::steam_client::steam_client::SteamClient;
12use crate::types::depot_manifest::{ChunkData, DepotManifest};
13
14use super::depot_chunk::DepotChunk;
15use super::server::{ConnectionProtocol, Server};
16
17/// The default timeout to use when making requests, in milliseconds.
18static REQUEST_TIMEOUT: AtomicU64 = AtomicU64::new(10_000);
19
20/// The default timeout to use when reading the response body, in milliseconds.
21static RESPONSE_BODY_TIMEOUT: AtomicU64 = AtomicU64::new(60_000);
22
23/// The version of the depot manifest format that is requested.
24const MANIFEST_VERSION: u32 = 5;
25
26/// Downloads game content from Steam's content servers.
27#[derive(Clone, Debug)]
28pub struct Client {
29    http_client: reqwest::Client,
30}
31
32impl Client {
33    /// Creates a client that uses the given HTTP client.
34    pub fn new(http_client: reqwest::Client) -> Self {
35        Client { http_client }
36    }
37
38    /// Creates a client using the HTTP client the Steam client's configuration
39    /// provides for CDN requests.
40    pub fn from_steam_client(steam_client: &SteamClient) -> Self {
41        let factory = steam_client.configuration().http_client_factory().clone();
42
43        Client::new(factory(HttpClientPurpose::CDN))
44    }
45
46    /// Creates a client with a default HTTP client.
47    pub fn with_default_client() -> Result<Self, SteamError> {
48        let http_client = reqwest::Client::builder()
49            .build()
50            .map_err(|error| SteamError::Connection(error.to_string()))?;
51
52        Ok(Client::new(http_client))
53    }
54
55    /// Default timeout to use when making requests.
56    pub fn request_timeout() -> Duration {
57        Duration::from_millis(REQUEST_TIMEOUT.load(Ordering::Relaxed))
58    }
59
60    /// Sets the default timeout to use when making requests.
61    pub fn set_request_timeout(value: Duration) {
62        REQUEST_TIMEOUT.store(value.as_millis() as u64, Ordering::Relaxed);
63    }
64
65    /// Default timeout to use when reading the response body.
66    pub fn response_body_timeout() -> Duration {
67        Duration::from_millis(RESPONSE_BODY_TIMEOUT.load(Ordering::Relaxed))
68    }
69
70    /// Sets the default timeout to use when reading the response body.
71    pub fn set_response_body_timeout(value: Duration) {
72        RESPONSE_BODY_TIMEOUT.store(value.as_millis() as u64, Ordering::Relaxed);
73    }
74
75    /// Downloads the depot manifest specified by the given manifest ID, and optionally
76    /// decrypts the manifest's filenames if the depot decryption key has been provided.
77    ///
78    /// * `depot_id` - the id of the depot being accessed.
79    /// * `manifest_id` - the unique identifier of the manifest to be downloaded.
80    /// * `manifest_request_code` - the manifest request code for the manifest that is
81    ///   being downloaded.
82    /// * `server` - the content server to connect to.
83    /// * `depot_key` - the depot decryption key for the depot that will be downloaded.
84    ///   This is used for decrypting filenames (if needed) in depot manifests.
85    /// * `proxy_server` - optional content server marked as `UseAsProxy` which
86    ///   transforms the request.
87    /// * `cdn_auth_token` - CDN auth token for CDN content server endpoints if
88    ///   necessary.
89    pub async fn download_manifest_async(
90        &self,
91        depot_id: u32,
92        manifest_id: u64,
93        manifest_request_code: u64,
94        server: &Server,
95        depot_key: Option<&[u8]>,
96        proxy_server: Option<&Server>,
97        cdn_auth_token: Option<&str>,
98    ) -> Result<DepotManifest, SteamError> {
99        let url = if manifest_request_code > 0 {
100            format!(
101                "depot/{depot_id}/manifest/{manifest_id}/{MANIFEST_VERSION}/{manifest_request_code}"
102            )
103        } else {
104            format!("depot/{depot_id}/manifest/{manifest_id}/{MANIFEST_VERSION}")
105        };
106
107        let built_url = Self::build_command(server, &url, cdn_auth_token, proxy_server)?;
108
109        let response = self
110            .send_request(self.http_client.get(built_url))
111            .await
112            .map_err(|error| {
113                tracing::debug!("Failed to download manifest: {error}");
114                error
115            })?;
116
117        let body = Self::read_body(response).await?;
118
119        // Decompress the zipped manifest data.
120        let manifest_data = extract_first_zip_entry(&body)?;
121
122        let mut depot_manifest = DepotManifest::deserialize(&manifest_data)?;
123
124        if let Some(depot_key) = depot_key {
125            // If we have the depot key, decrypt the manifest filenames.
126            depot_manifest.decrypt_filenames(depot_key);
127        }
128
129        Ok(depot_manifest)
130    }
131
132    /// Downloads the specified depot chunk, and optionally processes the chunk and
133    /// verifies the checksum if the depot decryption key has been provided.
134    ///
135    /// This function will also validate the length of the downloaded chunk with the
136    /// value of `ChunkData::compressed_length`, if it has been assigned a value.
137    ///
138    /// * `depot_id` - the id of the depot being accessed.
139    /// * `chunk` - the chunk to download. This value should come from a manifest
140    ///   downloaded with [`Client::download_manifest_async`].
141    /// * `server` - the content server to connect to.
142    /// * `destination` - the buffer to receive the chunk data. If `depot_key` is
143    ///   provided, this will be the decompressed buffer. Allocate a buffer that is equal
144    ///   or longer than `ChunkData::uncompressed_length`.
145    /// * `depot_key` - the depot decryption key for the depot that will be downloaded.
146    ///   This is used to process the chunk data.
147    /// * `proxy_server` - optional content server marked as `UseAsProxy` which
148    ///   transforms the request.
149    /// * `cdn_auth_token` - CDN auth token for CDN content server endpoints if
150    ///   necessary.
151    ///
152    /// Returns the total number of bytes written to `destination`.
153    pub async fn download_depot_chunk_async(
154        &self,
155        depot_id: u32,
156        chunk: &ChunkData,
157        server: &Server,
158        destination: &mut [u8],
159        depot_key: Option<&[u8]>,
160        proxy_server: Option<&Server>,
161        cdn_auth_token: Option<&str>,
162    ) -> Result<usize, SteamError> {
163        let chunk_id = chunk.chunk_id.as_ref().ok_or_else(|| {
164            SteamError::InvalidArgument("Chunk must have a ChunkID.".to_string())
165        })?;
166
167        match depot_key {
168            None => {
169                if destination.len() < chunk.compressed_length as usize {
170                    return Err(SteamError::InvalidArgument(format!(
171                        "The destination buffer must be longer than the chunk CompressedLength ({}) (since no depot key was provided).",
172                        chunk.compressed_length
173                    )));
174                }
175            }
176            Some(_) => {
177                if destination.len() < chunk.uncompressed_length as usize {
178                    return Err(SteamError::InvalidArgument(format!(
179                        "The destination buffer must be longer than the chunk UncompressedLength ({}).",
180                        chunk.uncompressed_length
181                    )));
182                }
183            }
184        }
185
186        let chunk_id = hex::encode(chunk_id);
187        let url = format!("depot/{depot_id}/chunk/{chunk_id}");
188
189        let request = if Self::use_lancache_server() {
190            Self::build_lancache_request(&self.http_client, server, &url, cdn_auth_token)?
191        } else {
192            let built_url = Self::build_command(server, &url, cdn_auth_token, proxy_server)?;
193            self.http_client.get(built_url)
194        };
195
196        let response = self.send_request(request).await.map_err(|error| {
197            tracing::debug!("Failed to download a depot chunk: {error}");
198            error
199        })?;
200
201        let mut content_length = chunk.compressed_length as usize;
202
203        if let Some(length) = response.content_length() {
204            content_length = length as usize;
205
206            // Only check the length if the chunk has one.
207            if chunk.compressed_length > 0 && content_length != chunk.compressed_length as usize {
208                return Err(SteamError::Malformed(format!(
209                    "Content-Length mismatch for depot chunk! (was {content_length}, but should be {})",
210                    chunk.compressed_length
211                )));
212            }
213        } else if content_length > 0 {
214            tracing::debug!(
215                "Response does not have Content-Length, falling back to chunk.CompressedLength."
216            );
217        } else {
218            return Err(SteamError::Other(
219                "Response does not have Content-Length and chunk.CompressedLength is not set."
220                    .to_string(),
221            ));
222        }
223
224        let body = Self::read_body(response).await?;
225
226        if body.len() != content_length {
227            return Err(SteamError::Malformed(format!(
228                "Length mismatch after downloading depot chunk! (was {}, but should be {content_length})",
229                body.len()
230            )));
231        }
232
233        match depot_key {
234            // No depot key: use the data as-is.
235            None => {
236                destination[..content_length].copy_from_slice(&body[..content_length]);
237
238                Ok(content_length)
239            }
240            // Decrypt and decompress the chunk.
241            Some(depot_key) => DepotChunk::process(chunk, &body[..content_length], destination, depot_key),
242        }
243    }
244
245    /// Builds the request URL for a CDN command.
246    ///
247    /// * `server` - the content server to connect to.
248    /// * `command` - the command (path) to request.
249    /// * `query` - the query string of the request, for example a CDN auth token.
250    /// * `proxy_server` - optional content server marked as `UseAsProxy` which
251    ///   transforms the request.
252    pub fn build_command(
253        server: &Server,
254        command: &str,
255        query: Option<&str>,
256        proxy_server: Option<&Server>,
257    ) -> Result<Url, SteamError> {
258        let mut scheme = match server.protocol {
259            ConnectionProtocol::Http => "http",
260            ConnectionProtocol::Https => "https",
261        };
262        let mut host = server.v_host.as_deref().ok_or_else(|| {
263            SteamError::InvalidArgument("The server does not have a virtual host.".to_string())
264        })?;
265        let mut port = server.port;
266        let mut path = format!("/{}", command.trim_start_matches('/'));
267
268        if let Some(proxy_server) = proxy_server {
269            if proxy_server.use_as_proxy {
270                if let Some(path_template) = proxy_server.proxy_request_path_template.as_deref() {
271                    path = path_template.replace("%host%", host).replace("%path%", &path);
272                    scheme = match proxy_server.protocol {
273                        ConnectionProtocol::Http => "http",
274                        ConnectionProtocol::Https => "https",
275                    };
276                    host = proxy_server.v_host.as_deref().ok_or_else(|| {
277                        SteamError::InvalidArgument(
278                            "The proxy server does not have a virtual host.".to_string(),
279                        )
280                    })?;
281                    port = proxy_server.port;
282                }
283            }
284        }
285
286        if !path.starts_with('/') {
287            path.insert(0, '/');
288        }
289
290        let mut url = Url::parse(&format!("{scheme}://{host}:{port}{path}"))
291            .map_err(|error| SteamError::InvalidArgument(format!("Invalid CDN url: {error}")))?;
292
293        if let Some(query) = query {
294            if !query.is_empty() {
295                url.set_query(Some(query));
296            }
297        }
298
299        Ok(url)
300    }
301
302    /// Sends a request and waits for the response headers.
303    async fn send_request(
304        &self,
305        request: reqwest::RequestBuilder,
306    ) -> Result<reqwest::Response, SteamError> {
307        let timeout = Self::request_timeout();
308
309        match tokio::time::timeout(timeout, request.send()).await {
310            Ok(result) => {
311                let response = result.map_err(|error| map_reqwest_error(error, timeout))?;
312
313                if !response.status().is_success() {
314                    return Err(SteamError::Other(format!(
315                        "Response status code does not indicate success: {}",
316                        response.status()
317                    )));
318                }
319
320                Ok(response)
321            }
322            Err(_) => Err(SteamError::Timeout(timeout)),
323        }
324    }
325
326    /// Reads the whole response body.
327    async fn read_body(response: reqwest::Response) -> Result<bytes::Bytes, SteamError> {
328        let timeout = Self::response_body_timeout();
329
330        match tokio::time::timeout(timeout, response.bytes()).await {
331            Ok(result) => result.map_err(|error| map_reqwest_error(error, timeout)),
332            Err(_) => Err(SteamError::Timeout(timeout)),
333        }
334    }
335}
336
337/// Maps a reqwest error to a [`SteamError`].
338fn map_reqwest_error(error: reqwest::Error, timeout: Duration) -> SteamError {
339    if error.is_timeout() {
340        SteamError::Timeout(timeout)
341    } else if error.is_connect() {
342        SteamError::Connection(error.to_string())
343    } else {
344        SteamError::Other(error.to_string())
345    }
346}
347
348/// Extracts the first entry of a ZIP archive. Reads the central directory
349/// directly and inflates the entry with [`flate2`].
350fn extract_first_zip_entry(data: &[u8]) -> Result<Vec<u8>, SteamError> {
351    const CENTRAL_FILE_HEADER: u32 = 0x0201_4b50;
352    const LOCAL_FILE_HEADER: u32 = 0x0403_4b50;
353
354    let eocd = find_end_of_central_directory(data)?;
355
356    let entry_count = read_u16(data, eocd + 10)? as usize;
357    let central_offset = read_u32(data, eocd + 16)? as usize;
358
359    if entry_count == 0 {
360        return Err(SteamError::Malformed(
361            "Expected the zip to contain only one file".to_string(),
362        ));
363    }
364
365    if read_u32(data, central_offset)? != CENTRAL_FILE_HEADER {
366        return Err(SteamError::Malformed(
367            "Invalid zip central directory header".to_string(),
368        ));
369    }
370
371    let method = read_u16(data, central_offset + 10)?;
372    let compressed_size = read_u32(data, central_offset + 20)? as usize;
373    let uncompressed_size = read_u32(data, central_offset + 24)? as usize;
374    let local_offset = read_u32(data, central_offset + 42)? as usize;
375
376    if read_u32(data, local_offset)? != LOCAL_FILE_HEADER {
377        return Err(SteamError::Malformed(
378            "Invalid zip local file header".to_string(),
379        ));
380    }
381
382    let local_name_length = read_u16(data, local_offset + 26)? as usize;
383    let local_extra_length = read_u16(data, local_offset + 28)? as usize;
384
385    let data_start = local_offset + 30 + local_name_length + local_extra_length;
386    let data_end = if compressed_size == 0 {
387        data.len()
388    } else {
389        data_start + compressed_size
390    };
391
392    if data_end > data.len() {
393        return Err(SteamError::Malformed(
394            "Truncated zip archive".to_string(),
395        ));
396    }
397
398    let compressed = &data[data_start..data_end];
399
400    match method {
401        0 => Ok(compressed.to_vec()),
402        8 => {
403            let mut output = Vec::with_capacity(uncompressed_size);
404            let mut decoder = flate2::read::DeflateDecoder::new(compressed);
405
406            decoder
407                .read_to_end(&mut output)
408                .map_err(|error| SteamError::Decompression(format!("Failed to inflate zip entry: {error}")))?;
409
410            Ok(output)
411        }
412        other => Err(SteamError::Decompression(format!(
413            "Unsupported zip compression method {other}"
414        ))),
415    }
416}
417
418/// Finds the end of central directory record of a ZIP archive.
419fn find_end_of_central_directory(data: &[u8]) -> Result<usize, SteamError> {
420    const END_OF_CENTRAL_DIRECTORY: u32 = 0x0605_4b50;
421
422    if data.len() < 22 {
423        return Err(SteamError::Malformed(
424            "The zip archive is too small".to_string(),
425        ));
426    }
427
428    let start = data.len().saturating_sub(22 + 0xFFFF);
429    let mut index = data.len() - 22;
430
431    loop {
432        if read_u32(data, index)? == END_OF_CENTRAL_DIRECTORY {
433            return Ok(index);
434        }
435
436        if index == start {
437            break;
438        }
439
440        index -= 1;
441    }
442
443    Err(SteamError::Malformed(
444        "Zip end of central directory record not found".to_string(),
445    ))
446}
447
448/// Reads a little endian `u16` from the given buffer.
449fn read_u16(data: &[u8], offset: usize) -> Result<u16, SteamError> {
450    let bytes = data
451        .get(offset..offset + 2)
452        .ok_or_else(|| SteamError::Malformed("Truncated zip archive".to_string()))?;
453
454    Ok(u16::from_le_bytes([bytes[0], bytes[1]]))
455}
456
457/// Reads a little endian `u32` from the given buffer.
458fn read_u32(data: &[u8], offset: usize) -> Result<u32, SteamError> {
459    let bytes = data
460        .get(offset..offset + 4)
461        .ok_or_else(|| SteamError::Malformed("Truncated zip archive".to_string()))?;
462
463    Ok(u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]))
464}