Skip to main content

steam_client/services/
cdn.rs

1//! Content delivery network (CDN) functionality for Steam client.
2//!
3//! This module provides functionality for downloading game content
4//! from Steam's CDN, including manifest parsing and file downloads.
5
6use sha1::{Digest, Sha1};
7
8use crate::{error::SteamError, SteamClient};
9
10/// A Steam content server.
11#[derive(Debug, Clone)]
12pub struct ContentServer {
13    /// Server type (e.g., "CDN", "SteamCache").
14    pub server_type: String,
15    /// Source ID.
16    pub source_id: u32,
17    /// Cell ID.
18    pub cell_id: u32,
19    /// Server load (lower is better).
20    pub load: f32,
21    /// Weighted load.
22    pub weighted_load: f32,
23    /// Host address.
24    pub host: String,
25    /// Virtual host.
26    pub vhost: String,
27    /// Whether HTTPS is supported.
28    pub https_support: bool,
29    /// Allowed app IDs (if restricted).
30    pub allowed_app_ids: Option<Vec<u32>>,
31}
32
33/// A CDN auth token for downloading content.
34#[derive(Debug, Clone)]
35pub struct CdnAuthToken {
36    /// The auth token.
37    pub token: String,
38    /// Expiration time (Unix timestamp).
39    pub expires: u64,
40    /// Hostname this token is valid for.
41    pub hostname: String,
42}
43
44/// A depot manifest describing game content.
45#[derive(Debug, Clone)]
46pub struct DepotManifest {
47    /// Depot ID.
48    pub depot_id: u32,
49    /// Manifest ID (GID).
50    pub manifest_id: u64,
51    /// Creation time (Unix timestamp).
52    pub creation_time: u32,
53    /// Total uncompressed size in bytes.
54    pub total_uncompressed_size: u64,
55    /// Total compressed size in bytes.
56    pub total_compressed_size: u64,
57    /// Unique chunk count.
58    pub unique_chunks: u32,
59    /// Number of files.
60    pub file_count: u32,
61    /// Files in this manifest.
62    pub files: Vec<ManifestFile>,
63}
64
65/// A file in a depot manifest.
66#[derive(Debug, Clone)]
67pub struct ManifestFile {
68    /// File path (relative).
69    pub filename: String,
70    /// File size in bytes.
71    pub size: u64,
72    /// File flags.
73    pub flags: u32,
74    /// SHA1 hash of content.
75    pub sha_content: Vec<u8>,
76    /// SHA1 hash of filename.
77    pub sha_filename: Vec<u8>,
78    /// Chunks that make up this file.
79    pub chunks: Vec<FileChunk>,
80}
81
82/// A chunk of a file.
83#[derive(Debug, Clone)]
84pub struct FileChunk {
85    /// SHA1 hash of the chunk (also used as chunk ID).
86    pub sha: Vec<u8>,
87    /// CRC32 of the chunk.
88    pub crc: u32,
89    /// Offset within the file.
90    pub offset: u64,
91    /// Compressed size.
92    pub compressed_size: u32,
93    /// Uncompressed size.
94    pub uncompressed_size: u32,
95}
96
97/// File flags for manifest files.
98#[allow(dead_code)]
99pub mod file_flags {
100    /// File is a directory.
101    pub const DIRECTORY: u32 = 0x40;
102    /// File is encrypted.
103    pub const EXECUTABLE: u32 = 0x80;
104    /// File is hidden.
105    pub const HIDDEN: u32 = 0x100;
106}
107
108impl SteamClient {
109    /// Get a list of currently available content servers.
110    ///
111    /// # Arguments
112    /// * `appid` - Optional app ID to filter servers that serve specific games
113    pub async fn get_content_servers(&mut self, appid: Option<u32>) -> Result<Vec<ContentServer>, SteamError> {
114        if !self.is_logged_in() {
115            return Err(SteamError::NotLoggedOn);
116        }
117
118        // Use the Web API to get content servers
119        let cell_id = self.account.read().cell_id.unwrap_or(0);
120        let cell_id_str = cell_id.to_string();
121
122        let resp = self.http_client.get_with_query("https://api.steampowered.com/IContentServerDirectoryService/GetServersForSteamPipe/v1/", &[("cell_id", cell_id_str.as_str())]).await?;
123
124        let json: serde_json::Value = resp.json()?;
125
126        let mut servers = Vec::new();
127
128        if let Some(server_list) = json["response"]["servers"].as_array() {
129            for server in server_list {
130                // Filter by app ID if specified
131                if let Some(app) = appid {
132                    if let Some(allowed) = server["allowed_app_ids"].as_array() {
133                        let allowed_ids: Vec<u32> = allowed.iter().filter_map(|v| v.as_u64().map(|n| n as u32)).collect();
134                        if !allowed_ids.is_empty() && !allowed_ids.contains(&app) {
135                            continue;
136                        }
137                    }
138                }
139
140                let server_type = server["type"].as_str().unwrap_or("").to_string();
141                if server_type != "CDN" && server_type != "SteamCache" {
142                    continue;
143                }
144
145                servers.push(ContentServer {
146                    server_type,
147                    source_id: server["source_id"].as_u64().unwrap_or(0) as u32,
148                    cell_id: server["cell_id"].as_u64().unwrap_or(0) as u32,
149                    load: server["load"].as_f64().unwrap_or(1.0) as f32,
150                    weighted_load: server["weighted_load"].as_f64().unwrap_or(1.0) as f32,
151                    host: server["host"].as_str().unwrap_or("").to_string(),
152                    vhost: server["vhost"].as_str().unwrap_or("").to_string(),
153                    https_support: server["https_support"].as_str() == Some("mandatory"),
154                    allowed_app_ids: server["allowed_app_ids"].as_array().map(|arr| arr.iter().filter_map(|v| v.as_u64().map(|n| n as u32)).collect()),
155                });
156            }
157        }
158
159        Ok(servers)
160    }
161
162    /// Request the decryption key for a particular depot.
163    ///
164    /// # Arguments
165    /// * `appid` - The app ID
166    /// * `depotid` - The depot ID
167    ///
168    /// # Returns
169    /// The depot decryption key (32 bytes).
170    pub async fn get_depot_decryption_key(&mut self, appid: u32, depotid: u32) -> Result<Vec<u8>, SteamError> {
171        if !self.is_logged_in() {
172            return Err(SteamError::NotLoggedOn);
173        }
174
175        let msg = steam_protos::CMsgClientGetDepotDecryptionKey { depot_id: Some(depotid), app_id: Some(appid) };
176
177        self.send_message(steam_enums::EMsg::ClientGetDepotDecryptionKey, &msg).await?;
178
179        // Return empty for now - proper implementation would wait for response
180        Ok(Vec::new())
181    }
182
183    /// Request decryption keys for a beta branch of an app from its beta
184    /// password.
185    ///
186    /// # Arguments
187    /// * `appid` - The app ID
188    /// * `password` - The beta password
189    pub async fn get_app_beta_decryption_keys(&mut self, appid: u32, password: &str) -> Result<std::collections::HashMap<String, Vec<u8>>, SteamError> {
190        if !self.is_logged_in() {
191            return Err(SteamError::NotLoggedOn);
192        }
193
194        let msg = steam_protos::CMsgClientCheckAppBetaPassword { app_id: Some(appid), betapassword: Some(password.to_string()) };
195
196        let resp: steam_protos::CMsgClientCheckAppBetaPasswordResponse = self.send_request_and_wait(steam_enums::EMsg::ClientCheckAppBetaPassword, &msg).await?;
197
198        if resp.eresult != Some(1) {
199            return Err(SteamError::SteamResult(steam_enums::EResult::from_i32(resp.eresult.unwrap_or(2)).unwrap_or(steam_enums::EResult::Fail)));
200        }
201
202        let mut branches = std::collections::HashMap::new();
203        for beta in resp.betapasswords {
204            if let (Some(name), Some(key)) = (beta.betaname, beta.betapassword) {
205                branches.insert(name, key);
206            }
207        }
208
209        Ok(branches)
210    }
211
212    /// Get a manifest request code for downloading a manifest.
213    ///
214    /// # Arguments
215    /// * `appid` - The app ID
216    /// * `depotid` - The depot ID
217    /// * `manifest_id` - The manifest ID
218    /// * `branch_name` - The branch name (default: "public")
219    /// * `branch_password` - Optional branch password
220    pub async fn get_manifest_request_code(&mut self, appid: u32, depotid: u32, manifest_id: u64, branch_name: Option<String>, branch_password: Option<String>) -> Result<u64, SteamError> {
221        if !self.is_logged_in() {
222            return Err(SteamError::NotLoggedOn);
223        }
224
225        let branch = branch_name.unwrap_or_else(|| "public".to_string());
226        let password_hash = branch_password.map(|p| {
227            let mut hasher = Sha1::new();
228            hasher.update(p.as_bytes());
229            hex::encode(hasher.finalize())
230        });
231
232        let req = steam_protos::CContentServerDirectoryGetManifestRequestCodeRequest {
233            app_id: Some(appid),
234            depot_id: Some(depotid),
235            manifest_id: Some(manifest_id),
236            app_branch: Some(branch),
237            branch_password_hash: password_hash,
238        };
239
240        let resp: steam_protos::CContentServerDirectoryGetManifestRequestCodeResponse = self.send_unified_request_and_wait("ContentServerDirectory.GetManifestRequestCode#1", &req).await?;
241
242        Ok(resp.manifest_request_code.unwrap_or(0))
243    }
244
245    /// Get an auth token for a particular CDN server.
246    ///
247    /// # Arguments
248    /// * `appid` - The app ID
249    /// * `depotid` - The depot ID
250    /// * `hostname` - The hostname of the CDN server
251    pub async fn get_cdn_auth_token(&mut self, appid: u32, depotid: u32, hostname: &str) -> Result<CdnAuthToken, SteamError> {
252        if !self.is_logged_in() {
253            return Err(SteamError::NotLoggedOn);
254        }
255
256        let msg = steam_protos::CMsgClientGetCdnAuthToken { depot_id: Some(depotid), host_name: Some(hostname.to_string()), app_id: Some(appid) };
257
258        self.send_message(steam_enums::EMsg::ClientGetCDNAuthToken, &msg).await?;
259
260        // Return placeholder for now - proper implementation would wait for response
261        Ok(CdnAuthToken { token: String::new(), expires: 0, hostname: hostname.to_string() })
262    }
263
264    /// Get a manifest for a depot.
265    ///
266    /// # Arguments
267    /// * `appid` - The app ID
268    /// * `depotid` - The depot ID
269    /// * `manifest_id` - The manifest ID
270    /// * `branch_name` - The branch name (default: "public")
271    /// * `branch_password` - Optional branch password
272    /// * `server` - Content server to download from
273    /// * `depot_key` - Depot decryption key
274    #[allow(clippy::too_many_arguments)]
275    pub async fn get_manifest(&mut self, appid: u32, depotid: u32, manifest_id: u64, branch_name: Option<String>, branch_password: Option<String>, server: &ContentServer, _depot_key: &[u8]) -> Result<DepotManifest, SteamError> {
276        // Build the manifest URL
277        let scheme = if server.https_support { "https" } else { "http" };
278        let host = if !server.vhost.is_empty() { &server.vhost } else { &server.host };
279
280        // Get manifest request code
281        let request_code = self.get_manifest_request_code(appid, depotid, manifest_id, branch_name, branch_password).await?;
282
283        let url = format!("{}://{}/depot/{}/manifest/{}/5/{}", scheme, host, depotid, manifest_id, request_code);
284
285        // Get CDN auth token
286        let auth_token = self.get_cdn_auth_token(appid, depotid, host).await?;
287
288        // Download the manifest
289        let resp = self.http_client.get_with_query(&url, &[("t", auth_token.token.as_str())]).await?;
290
291        if !resp.is_success() {
292            return Err(SteamError::Other(format!("Failed to download manifest: {}", resp.status)));
293        }
294
295        // Parse the manifest (would need manifest parsing code)
296        // For now return a placeholder
297        Ok(DepotManifest {
298            depot_id: depotid,
299            manifest_id,
300            creation_time: 0,
301            total_uncompressed_size: 0,
302            total_compressed_size: 0,
303            unique_chunks: 0,
304            file_count: 0,
305            files: Vec::new(),
306        })
307    }
308
309    /// Download a raw (encrypted/compressed) chunk from a content server.
310    ///
311    /// # Warning
312    /// This returns raw chunk data that is still **encrypted** and
313    /// **compressed**. To use this data, you must:
314    /// 1. Decrypt using the depot key (AES-256-ECB, first 16 bytes are IV)
315    /// 2. Decompress (LZMA or ZIP depending on format)
316    /// 3. Verify CRC32 matches the chunk's `crc` field
317    ///
318    /// # Arguments
319    /// * `appid` - The app ID (for CDN authentication)
320    /// * `depotid` - The depot ID
321    /// * `chunk` - The chunk to download
322    /// * `server` - Content server to download from
323    /// * `_depot_key` - Depot decryption key (not used yet, for future
324    ///   decryption)
325    pub async fn download_chunk_raw(&mut self, appid: u32, depotid: u32, chunk: &FileChunk, server: &ContentServer, _depot_key: &[u8]) -> Result<Vec<u8>, SteamError> {
326        let scheme = if server.https_support { "https" } else { "http" };
327        let host = if !server.vhost.is_empty() { &server.vhost } else { &server.host };
328        let chunk_id = hex::encode(&chunk.sha);
329        let url = format!("{}://{}/depot/{}/chunk/{}", scheme, host, depotid, chunk_id);
330
331        // Get CDN auth token for this server
332        let auth_token = self.get_cdn_auth_token(appid, depotid, host).await?;
333
334        let resp = self.http_client.get_with_query(&url, &[("t", auth_token.token.as_str())]).await?;
335
336        if !resp.is_success() {
337            return Err(SteamError::Other(format!("Failed to download chunk: {}", resp.status)));
338        }
339
340        Ok(resp.body)
341    }
342}