supreme_fs/game_storage/online/
content.rs1use crate::game_storage::GameStorage;
4use crate::game_storage::online::multipart::{MultipartForm, SendMultipart};
5use crate::{AssetType, HybridStorageError, OnlineStorageError};
6use sha2::{Digest, Sha256};
7use std::fs;
8use std::io::{self, Read};
9
10fn verify_hash(data: &[u8], expected_hash: &str) -> Result<(), HybridStorageError> {
16 let mut hasher = Sha256::new();
17 hasher.update(data);
18 let actual_hash = hex::encode(hasher.finalize());
19 if actual_hash == expected_hash {
20 Ok(())
21 } else {
22 Err(HybridStorageError::CorruptedDownload(
23 expected_hash.to_string(),
24 ))
25 }
26}
27
28fn decode_and_verify_entry(
34 filename: &str,
35 raw: &[u8],
36) -> Result<(String, Vec<u8>), HybridStorageError> {
37 let (hash, is_xz) = match filename.strip_suffix(".xz") {
38 Some(stripped) => (stripped.to_string(), true),
39 None => (filename.to_string(), false),
40 };
41
42 let final_data = if is_xz {
43 let mut decoder = xz2::read::XzDecoder::new(raw);
44 let mut decompressed = Vec::new();
45 decoder.read_to_end(&mut decompressed)?;
46 decompressed
47 } else {
48 raw.to_vec()
49 };
50
51 verify_hash(&final_data, &hash)?;
52 Ok((hash, final_data))
53}
54
55impl GameStorage {
56 pub(crate) fn fetch_online(
57 &self,
58 asset_type: AssetType,
59 filename: &str,
60 ) -> Result<(Vec<u8>, String), OnlineStorageError> {
61 let client = self
62 .client
63 .as_ref()
64 .ok_or_else(|| io::Error::other("Online storage not configured"))?;
65 let api_url = self
66 .api_url
67 .as_ref()
68 .ok_or_else(|| io::Error::other("Online storage not configured"))?;
69
70 let sub_path = match asset_type {
71 AssetType::Content => "contents",
72 AssetType::Profile => "profiles",
73 };
74 let url = format!("{api_url}/{sub_path}/{filename}");
75
76 let response = client.get(&url).call()?;
77 if response.status().is_success() {
78 let actual_filename = response
79 .headers()
80 .get("X-Content-Filename")
81 .and_then(|h| h.to_str().ok())
82 .unwrap_or(filename)
83 .to_string();
84
85 let mut bytes = Vec::new();
86 let mut reader = response.into_body().into_reader();
87 reader.read_to_end(&mut bytes)?;
88 Ok((bytes, actual_filename))
89 } else {
90 Err(OnlineStorageError::NotFound(format!(
91 "URL: {sub_path}/{filename}"
92 )))
93 }
94 }
95
96 pub fn fetch_contents_batch(
97 &self,
98 hashes: &[&str],
99 ) -> Result<Vec<(String, Vec<u8>)>, HybridStorageError> {
100 let client = self
101 .client
102 .as_ref()
103 .ok_or_else(|| io::Error::other("Online storage not configured"))?;
104 let api_url = self
105 .api_url
106 .as_ref()
107 .ok_or_else(|| io::Error::other("Online storage not configured"))?;
108
109 let mut form = MultipartForm::new().text("action", "fetch_contents_batch");
110
111 for hash in hashes {
112 form = form.text("hashes[]", *hash);
113 }
114
115 if let Some(token) = &self.token {
116 form = form.text("api_token", token.clone());
117 }
118
119 let mut request = client.post(api_url);
120 if let Some(ref token) = self.token {
121 request = request.header("X-SSBZ-Token", token);
122 }
123
124 let response = request.send_multipart(form)?;
125 if !response.status().is_success() {
126 let err_msg = self.extract_error_message(response);
127 if err_msg.contains("404") {
128 return Err(HybridStorageError::NotFound(
129 "None of the requested hashes found".to_string(),
130 ));
131 }
132 return Err(HybridStorageError::UploadFailed(err_msg));
133 }
134
135 let mut tar_bytes = Vec::new();
136 response
137 .into_body()
138 .into_reader()
139 .read_to_end(&mut tar_bytes)?;
140
141 let mut archive = tar::Archive::new(std::io::Cursor::new(tar_bytes));
142 let mut results = Vec::new();
143
144 for entry in archive.entries()? {
145 let mut entry = entry?;
146 let path = entry.path()?.to_path_buf();
147 let filename = path
148 .file_name()
149 .and_then(|s| s.to_str())
150 .unwrap_or_default()
151 .to_string();
152
153 let mut content = Vec::new();
154 entry.read_to_end(&mut content)?;
155
156 let (hash, final_data) = decode_and_verify_entry(&filename, &content)?;
157
158 let save_path = self.contents_path().join(&filename);
160 fs::create_dir_all(
161 save_path
162 .parent()
163 .ok_or_else(|| io::Error::other("Save path has no parent directory"))?,
164 )?;
165 fs::write(&save_path, &content)?;
166
167 results.push((hash, final_data));
168 }
169
170 Ok(results)
171 }
172
173 pub fn get_content(&self, hash: &str) -> Result<Vec<u8>, HybridStorageError> {
183 self.get_content_with_retries(hash, 3)
184 }
185
186 pub fn get_content_once(&self, hash: &str) -> Result<Vec<u8>, HybridStorageError> {
203 self.get_content_with_retries(hash, 1)
204 }
205
206 fn get_content_with_retries(
207 &self,
208 hash: &str,
209 attempts: u32,
210 ) -> Result<Vec<u8>, HybridStorageError> {
211 let local_path_xz = self.contents_path().join(format!("{hash}.xz"));
212 let local_path_raw = self.contents_path().join(hash);
213
214 if local_path_xz.exists() {
215 let compressed_data = fs::read(&local_path_xz)?;
216 let mut decoder = xz2::read::XzDecoder::new(compressed_data.as_slice());
217 let mut decompressed = Vec::new();
218 decoder.read_to_end(&mut decompressed)?;
219 return Ok(decompressed);
220 }
221
222 if local_path_raw.exists() {
223 return Ok(fs::read(&local_path_raw)?);
224 }
225
226 if self.api_url.is_some() {
227 let mut last_error = None;
228 let mut data = None;
229 let mut remote_filename = String::new();
230
231 for attempt in 1..=attempts {
232 match self.fetch_online(AssetType::Content, hash) {
233 Ok((d, f)) => {
234 data = Some(d);
235 remote_filename = f;
236 break;
237 }
238 Err(e) => {
239 last_error = Some(e);
240 if attempt < attempts {
241 std::thread::sleep(std::time::Duration::from_millis(
242 500 * attempt as u64,
243 ));
244 }
245 }
246 }
247 }
248
249 let data = match data {
250 Some(d) => d,
251 None => {
252 return Err(HybridStorageError::OnlineError(
253 last_error.unwrap_or(OnlineStorageError::NotFound(hash.to_string())),
254 ));
255 }
256 };
257
258 let is_xz = remote_filename.ends_with(".xz");
259 let save_path = self.contents_path().join(&remote_filename);
260
261 fs::create_dir_all(
262 save_path
263 .parent()
264 .ok_or_else(|| io::Error::other("Save path has no parent directory"))?,
265 )?;
266 fs::write(&save_path, &data)?;
267
268 let final_data = if is_xz {
269 let mut decoder = xz2::read::XzDecoder::new(data.as_slice());
270 let mut decompressed = Vec::new();
271 decoder.read_to_end(&mut decompressed)?;
272 decompressed
273 } else {
274 data
275 };
276
277 verify_hash(&final_data, hash)?;
279
280 Ok(final_data)
281 } else {
282 Err(HybridStorageError::NotFound(hash.to_string()))
283 }
284 }
285
286 pub fn upload_content(&self, hash: &str, data: &[u8]) -> Result<(), HybridStorageError> {
287 if self.api_url.is_some() {
288 let content_path_xz = self.contents_path().join(format!("{hash}.xz"));
289 if let Ok(compressed_data) = fs::read(&content_path_xz) {
290 self.upload_content_file(&format!("{hash}.xz"), &compressed_data)?;
291 } else {
292 self.upload_content_file(hash, data)?;
293 }
294 }
295
296 Ok(())
297 }
298
299 pub(crate) fn upload_content_file(
301 &self,
302 filename: &str,
303 data: &[u8],
304 ) -> Result<(), HybridStorageError> {
305 self.upload_file_unified("contents", data, "content", Some(filename), None)
306 }
307}
308
309#[cfg(test)]
310mod tests {
311 use super::*;
312 use tempfile::tempdir;
313
314 #[test]
315 fn get_content_returns_local_raw_content_without_network() {
316 let dir = tempdir().unwrap();
317 let storage = GameStorage::new(dir.path());
318 fs::create_dir_all(storage.contents_path()).unwrap();
319 let hash = "deadbeef";
320 fs::write(storage.contents_path().join(hash), b"payload").unwrap();
321
322 let data = storage.get_content(hash).expect("get_content");
323 assert_eq!(data, b"payload");
324 }
325
326 #[test]
327 fn get_content_prefers_and_decompresses_xz_cache_over_raw() {
328 let dir = tempdir().unwrap();
329 let storage = GameStorage::new(dir.path());
330 fs::create_dir_all(storage.contents_path()).unwrap();
331 let hash = "cafef00d";
332
333 let raw = b"hello xz world";
334 let compressed = xz2::write::XzEncoder::new(Vec::new(), 6)
335 .and_then_finish(raw)
336 .expect("compress");
337
338 fs::write(
339 storage.contents_path().join(format!("{hash}.xz")),
340 &compressed,
341 )
342 .unwrap();
343
344 let data = storage.get_content(hash).expect("get_content");
345 assert_eq!(data, raw);
346 }
347
348 #[test]
349 fn get_content_returns_not_found_when_offline_and_uncached() {
350 let dir = tempdir().unwrap();
351 let storage = GameStorage::new(dir.path());
352 let result = storage.get_content("missing_hash");
353 assert!(matches!(result, Err(HybridStorageError::NotFound(_))));
354 }
355
356 #[test]
357 fn get_content_once_returns_local_raw_content_without_network() {
358 let dir = tempdir().unwrap();
359 let storage = GameStorage::new(dir.path());
360 fs::create_dir_all(storage.contents_path()).unwrap();
361 let hash = "feedface";
362 fs::write(storage.contents_path().join(hash), b"payload").unwrap();
363
364 let data = storage.get_content_once(hash).expect("get_content_once");
365 assert_eq!(data, b"payload");
366 }
367
368 #[test]
369 fn upload_content_is_a_no_op_when_offline() {
370 let dir = tempdir().unwrap();
371 let storage = GameStorage::new(dir.path());
372 let result = storage.upload_content("somehash", b"data");
375 assert!(result.is_ok());
376 }
377
378 trait AndThenFinish {
380 fn and_then_finish(self, data: &[u8]) -> io::Result<Vec<u8>>;
381 }
382 impl AndThenFinish for xz2::write::XzEncoder<Vec<u8>> {
383 fn and_then_finish(mut self, data: &[u8]) -> io::Result<Vec<u8>> {
384 use std::io::Write;
385 self.write_all(data)?;
386 self.finish()
387 }
388 }
389
390 fn corrupt_chunk_hash(original_hash: &str) -> String {
394 let mut corrupted = original_hash.to_string();
395 if let Some(ch) = corrupted.chars().nth(32) {
396 let new_ch = if ch == 'a' { 'b' } else { 'a' };
397 corrupted.replace_range(32..33, &new_ch.to_string());
398 }
399 corrupted
400 }
401
402 fn corrupt_chunk_data(data: &[u8]) -> Vec<u8> {
406 let mut corrupted = data.to_vec();
407 if corrupted.len() > 100 {
408 corrupted[50] = corrupted[50].wrapping_add(1);
409 corrupted[51] = corrupted[51].wrapping_add(1);
410 }
411 corrupted
412 }
413
414 fn sha256_hex(data: &[u8]) -> String {
415 let mut hasher = Sha256::new();
416 hasher.update(data);
417 hex::encode(hasher.finalize())
418 }
419
420 #[test]
421 fn verify_hash_accepts_matching_data_and_hash() {
422 let data = generate_test_content(256);
423 let hash = sha256_hex(&data);
424 assert!(verify_hash(&data, &hash).is_ok());
425 }
426
427 #[test]
428 fn verify_hash_rejects_data_corrupted_with_corrupt_chunk_data() {
429 let data = generate_test_content(256);
430 let hash = sha256_hex(&data);
431 let corrupted = corrupt_chunk_data(&data);
432
433 let result = verify_hash(&corrupted, &hash);
434 match result {
435 Err(HybridStorageError::CorruptedDownload(reported_hash)) => {
436 assert_eq!(reported_hash, hash);
437 }
438 other => panic!("expected CorruptedDownload, got {other:?}"),
439 }
440 }
441
442 #[test]
443 fn verify_hash_rejects_hash_corrupted_with_corrupt_chunk_hash() {
444 let data = generate_test_content(256);
445 let hash = sha256_hex(&data);
446 let bad_hash = corrupt_chunk_hash(&hash);
447 assert_ne!(
448 bad_hash, hash,
449 "helper should have actually changed the hash"
450 );
451
452 let result = verify_hash(&data, &bad_hash);
453 match result {
454 Err(HybridStorageError::CorruptedDownload(reported_hash)) => {
455 assert_eq!(reported_hash, bad_hash);
456 }
457 other => panic!("expected CorruptedDownload, got {other:?}"),
458 }
459 }
460
461 #[test]
462 fn decode_and_verify_entry_accepts_uncompressed_entry_with_matching_hash() {
463 let data = generate_test_content(128);
464 let hash = sha256_hex(&data);
465
466 let (returned_hash, decoded) = decode_and_verify_entry(&hash, &data).expect("decode");
467 assert_eq!(returned_hash, hash);
468 assert_eq!(decoded, data);
469 }
470
471 #[test]
472 fn decode_and_verify_entry_rejects_corrupted_uncompressed_content() {
473 let data = generate_test_content(256);
474 let hash = sha256_hex(&data);
475 let corrupted = corrupt_chunk_data(&data);
476
477 let result = decode_and_verify_entry(&hash, &corrupted);
478 assert!(matches!(
479 result,
480 Err(HybridStorageError::CorruptedDownload(reported_hash)) if reported_hash == hash
481 ));
482 }
483
484 #[test]
485 fn decode_and_verify_entry_decompresses_and_accepts_matching_xz_entry() {
486 let raw = generate_test_content(512);
487 let hash = sha256_hex(&raw);
488 let compressed = xz2::write::XzEncoder::new(Vec::new(), 6)
489 .and_then_finish(&raw)
490 .expect("compress");
491 let filename = format!("{hash}.xz");
492
493 let (returned_hash, decoded) =
494 decode_and_verify_entry(&filename, &compressed).expect("decode xz");
495 assert_eq!(returned_hash, hash);
496 assert_eq!(decoded, raw);
497 }
498
499 #[test]
500 fn decode_and_verify_entry_rejects_xz_entry_whose_decompressed_content_was_corrupted() {
501 let raw = generate_test_content(512);
502 let hash = sha256_hex(&raw);
503 let corrupted_raw = corrupt_chunk_data(&raw);
504 let compressed_corrupted = xz2::write::XzEncoder::new(Vec::new(), 6)
505 .and_then_finish(&corrupted_raw)
506 .expect("compress");
507 let filename = format!("{hash}.xz");
508
509 let result = decode_and_verify_entry(&filename, &compressed_corrupted);
510 assert!(matches!(
511 result,
512 Err(HybridStorageError::CorruptedDownload(reported_hash)) if reported_hash == hash
513 ));
514 }
515
516 fn generate_test_content(size: usize) -> Vec<u8> {
521 let mut content = Vec::with_capacity(size);
522 let mut seed = size as u64;
523 for i in 0..size {
524 seed = seed.wrapping_mul(1_103_515_245).wrapping_add(12345);
525 content.push(((seed / 65536) % 256) as u8);
526 if i % 256 == 0 {
527 seed = seed.wrapping_add(i as u64);
528 }
529 }
530 content
531 }
532}