1#![deny(missing_docs)]
12
13use crate::error::{Error, Result};
14use crate::models::{Package, PackageIndex, SerializedPackageIndex};
15use crate::progress::{NoopProgress, ProgressReporter};
16use crate::retry::{RetryPolicy, retry_http};
17use crate::util::{SizeCap, io_context};
18
19use chrono::prelude::*;
20use futures::stream::StreamExt;
21use reqwest::Client;
22use reqwest::header;
23use std::collections::HashMap;
24use std::io::Read;
25use std::path::{Path, PathBuf};
26use std::sync::Arc;
27use std::sync::atomic::{AtomicU64, Ordering};
28use std::time::Duration;
29use tokio::fs;
30use tokio::io::{AsyncReadExt, AsyncWriteExt};
31
32const DEFAULT_BASE_URL: &str = "https://thunderstore.io";
34const LAST_MODIFIED_FILENAME: &str = "last_modified";
35const API_MANIFEST_FILENAME: &str = "api_manifest.bin.zst";
36
37const INDEX_CACHE_SUBDIR: &str = "index";
44
45const DEFAULT_MAX_DOWNLOAD_BYTES: u64 = 2 * 1024 * 1024 * 1024;
50
51const DEFAULT_MAX_MANIFEST_BYTES: u64 = 512 * 1024 * 1024;
58
59const DEFAULT_MAX_MANIFEST_DECOMPRESSED_BYTES: usize = 512 * 1024 * 1024;
75
76const MAX_DECODE_ALLOC: usize = 1024 * 1024 * 1024;
85
86const MAX_LAST_MODIFIED_BYTES: u64 = 4096;
90
91const PROFILE_API_PATH: &str = "api/experimental/legacyprofile";
94
95const MAX_PROFILE_CODE_BYTES: u64 = 64 * 1024 * 1024;
99
100pub struct ThunderstoreClient {
104 http: Client,
105 package_index_url: String,
106 base_url: String,
107 cache_dir: PathBuf,
108 index_cache_key: String,
109 progress: Arc<dyn ProgressReporter>,
110 download_concurrency: usize,
111 max_download_bytes: Option<u64>,
112 max_manifest_bytes: Option<u64>,
113 max_manifest_decompressed_bytes: usize,
114 retry: RetryPolicy,
115}
116
117impl ThunderstoreClient {
118 pub fn builder() -> ThunderstoreClientBuilder {
144 ThunderstoreClientBuilder::default()
145 }
146
147 pub fn package_index_url(&self) -> &str {
149 &self.package_index_url
150 }
151
152 pub fn base_url(&self) -> &str {
154 &self.base_url
155 }
156
157 pub fn cache_dir(&self) -> &Path {
159 &self.cache_dir
160 }
161
162 fn index_cache_dir(&self) -> PathBuf {
169 self
170 .cache_dir
171 .join(INDEX_CACHE_SUBDIR)
172 .join(&self.index_cache_key)
173 }
174
175 pub async fn get_manifest(&self) -> Result<PackageIndex> {
196 let index_dir = self.index_cache_dir();
197 let last_modified = local_last_modified(&index_dir).await?;
198 tracing::info!("Manifest last modified: {}", last_modified);
199
200 if api_manifest_file_exists(&index_dir) {
201 match network_last_modified(&self.http, &self.package_index_url).await {
202 Ok(network) if is_fresh(network, last_modified) => {
203 tracing::info!("Loading manifest from cache");
204
205 if let Some(manifest) =
206 load_cached_index(&index_dir, self.max_manifest_decompressed_bytes).await
207 {
208 return Ok(manifest);
209 }
210 }
211 Ok(_) => tracing::info!("Cache is stale; downloading new manifest"),
212 Err(err) => {
213 tracing::warn!("Freshness check failed ({}); serving cached manifest", err);
214
215 if let Some(manifest) =
216 load_cached_index(&index_dir, self.max_manifest_decompressed_bytes).await
217 {
218 return Ok(manifest);
219 }
220 }
221 }
222 }
223
224 tracing::info!("Downloading new manifest");
225
226 get_manifest_from_network_and_cache(
227 &index_dir,
228 &self.http,
229 &self.package_index_url,
230 self.max_manifest_bytes,
231 &self.retry,
232 self.progress.as_ref(),
233 )
234 .await
235 }
236
237 pub async fn refresh_index(&self) -> Result<PackageIndex> {
245 get_manifest_from_network_and_cache(
246 &self.index_cache_dir(),
247 &self.http,
248 &self.package_index_url,
249 self.max_manifest_bytes,
250 &self.retry,
251 self.progress.as_ref(),
252 )
253 .await
254 }
255
256 pub async fn download_files(&self, urls: HashMap<String, String>) -> Result<()> {
278 download_files(
279 urls,
280 &self.cache_dir,
281 &self.http,
282 self.download_concurrency,
283 self.max_download_bytes,
284 self.retry,
285 self.progress.clone(),
286 )
287 .await
288 }
289
290 pub async fn create_profile_code(&self, payload: &[u8]) -> Result<String> {
305 let url = format!("{}/{}/create/", self.base_url, PROFILE_API_PATH);
306
307 let key = retry_http(&self.retry, || async {
308 let response = self
309 .http
310 .post(&url)
311 .header(header::CONTENT_TYPE, "application/octet-stream")
312 .body(payload.to_vec())
313 .send()
314 .await?
315 .error_for_status()?;
316
317 let body = read_capped_body(response, Some(MAX_PROFILE_CODE_BYTES)).await?;
318 let parsed: ProfileCodeResponse = serde_json::from_slice(&body)?;
319
320 Ok(parsed.key)
321 })
322 .await?;
323
324 Ok(key)
325 }
326
327 pub async fn fetch_profile_code(&self, code: &str) -> Result<Vec<u8>> {
343 validate_profile_code(code)?;
344
345 let url = format!("{}/{}/get/{}/", self.base_url, PROFILE_API_PATH, code);
346
347 let body = retry_http(&self.retry, || async {
348 let response = self.http.get(&url).send().await?.error_for_status()?;
349
350 read_capped_body(response, Some(MAX_PROFILE_CODE_BYTES)).await
351 })
352 .await?;
353
354 Ok(body)
355 }
356}
357
358pub struct ThunderstoreClientBuilder {
360 base_url: Option<String>,
361 community: Option<String>,
362 package_index_url: Option<String>,
363 cache_dir: Option<PathBuf>,
364 progress: Option<Arc<dyn ProgressReporter>>,
365 download_concurrency: usize,
366 download_timeout: Duration,
367 max_download_bytes: Option<u64>,
368 max_manifest_bytes: Option<u64>,
369 max_manifest_decompressed_bytes: Option<u64>,
370 retry: RetryPolicy,
371}
372
373impl Default for ThunderstoreClientBuilder {
374 fn default() -> Self {
375 Self {
376 base_url: None,
377 community: None,
378 package_index_url: None,
379 cache_dir: None,
380 progress: None,
381 download_concurrency: 2,
382 download_timeout: Duration::from_secs(60),
383 max_download_bytes: Some(DEFAULT_MAX_DOWNLOAD_BYTES),
384 max_manifest_bytes: Some(DEFAULT_MAX_MANIFEST_BYTES),
385 max_manifest_decompressed_bytes: None,
388 retry: RetryPolicy::default(),
389 }
390 }
391}
392
393impl ThunderstoreClientBuilder {
394 pub fn base_url(mut self, base_url: impl Into<String>) -> Self {
396 self.base_url = Some(base_url.into());
397
398 self
399 }
400
401 pub fn community(mut self, community: impl Into<String>) -> Self {
404 self.community = Some(community.into());
405
406 self
407 }
408
409 pub fn package_index_url(mut self, url: impl Into<String>) -> Self {
413 self.package_index_url = Some(url.into());
414
415 self
416 }
417
418 pub fn cache_dir(mut self, cache_dir: impl Into<PathBuf>) -> Self {
420 self.cache_dir = Some(cache_dir.into());
421
422 self
423 }
424
425 pub fn progress(mut self, progress: Arc<dyn ProgressReporter>) -> Self {
427 self.progress = Some(progress);
428
429 self
430 }
431
432 pub fn download_concurrency(mut self, concurrency: usize) -> Self {
434 self.download_concurrency = concurrency;
435
436 self
437 }
438
439 pub fn download_timeout(mut self, timeout: Duration) -> Self {
444 self.download_timeout = timeout;
445
446 self
447 }
448
449 pub fn manifest_max_bytes(mut self, max_bytes: u64) -> Self {
460 self.max_manifest_bytes = Some(max_bytes);
461
462 self
463 }
464
465 pub fn manifest_max_decompressed_bytes(mut self, max_bytes: u64) -> Self {
481 self.max_manifest_decompressed_bytes = Some(max_bytes);
482
483 self
484 }
485
486 pub fn retry(mut self, policy: RetryPolicy) -> Self {
492 self.retry = policy;
493
494 self
495 }
496
497 pub fn max_retries(mut self, max_retries: u32) -> Self {
500 self.retry.max_retries = max_retries;
501
502 self
503 }
504
505 pub fn download_max_bytes(mut self, max_bytes: u64) -> Self {
512 self.max_download_bytes = Some(max_bytes);
513
514 self
515 }
516
517 pub fn build(self) -> Result<ThunderstoreClient> {
524 let cache_dir = self
525 .cache_dir
526 .ok_or_else(|| Error::Config("cache_dir is required".to_string()))?;
527
528 let base_url = self
529 .base_url
530 .clone()
531 .unwrap_or_else(|| DEFAULT_BASE_URL.to_string())
532 .trim_end_matches('/')
533 .to_string();
534
535 let package_index_url = compose_index_url(
536 self.base_url.as_deref().unwrap_or(DEFAULT_BASE_URL),
537 self.community.as_deref(),
538 self.package_index_url.as_deref(),
539 )?;
540
541 let index_cache_key = format!("{:08x}", crc32fast::hash(package_index_url.as_bytes()));
542
543 let max_manifest_decompressed_bytes = resolve_decompressed_cap(
544 self.max_manifest_decompressed_bytes,
545 self.max_manifest_bytes,
546 );
547
548 let http = Client::builder()
555 .connect_timeout(self.download_timeout)
556 .read_timeout(self.download_timeout)
557 .redirect(reqwest::redirect::Policy::limited(10))
558 .build()?;
559 let progress = self.progress.unwrap_or_else(|| Arc::new(NoopProgress));
560
561 Ok(ThunderstoreClient {
562 http,
563 package_index_url,
564 base_url,
565 cache_dir,
566 index_cache_key,
567 progress,
568 download_concurrency: self.download_concurrency,
569 max_download_bytes: self.max_download_bytes,
570 max_manifest_bytes: self.max_manifest_bytes,
571 max_manifest_decompressed_bytes,
572 retry: self.retry,
573 })
574 }
575}
576
577fn validate_community(community: &str) -> Result<()> {
581 let is_slug = !community.is_empty()
582 && community
583 .chars()
584 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_');
585
586 if !is_slug {
587 return Err(Error::Config(format!(
588 "invalid community slug {:?}: expected only ASCII letters, digits, '-', and '_'",
589 community
590 )));
591 }
592
593 Ok(())
594}
595
596fn compose_index_url(
604 base_url: &str,
605 community: Option<&str>,
606 override_url: Option<&str>,
607) -> Result<String> {
608 if let Some(url) = override_url {
609 return Ok(url.to_string());
610 }
611
612 let community = community.ok_or_else(|| Error::Config("community is required".to_string()))?;
613
614 validate_community(community)?;
615
616 Ok(format!(
617 "{}/c/{}/api/v1/package/",
618 base_url.trim_end_matches('/'),
619 community
620 ))
621}
622
623#[derive(serde::Deserialize)]
625struct ProfileCodeResponse {
626 key: String,
627}
628
629fn validate_profile_code(code: &str) -> Result<()> {
633 let is_valid = !code.is_empty()
634 && code
635 .chars()
636 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_');
637
638 if !is_valid {
639 return Err(Error::Config(format!(
640 "invalid profile code {code:?}: expected only ASCII letters, digits, '-', and '_'"
641 )));
642 }
643
644 Ok(())
645}
646
647fn resolve_decompressed_cap(configured: Option<u64>, download_cap: Option<u64>) -> usize {
655 configured
656 .or(download_cap)
657 .map(|n| usize::try_from(n).unwrap_or(usize::MAX))
658 .unwrap_or(DEFAULT_MAX_MANIFEST_DECOMPRESSED_BYTES)
659}
660
661fn last_modified_path(cache_dir: &Path) -> PathBuf {
663 cache_dir.join(LAST_MODIFIED_FILENAME)
664}
665
666fn api_manifest_path(cache_dir: &Path) -> PathBuf {
668 cache_dir.join(API_MANIFEST_FILENAME)
669}
670
671#[cfg(test)]
683async fn get_manifest_from_disk(cache_dir: &Path) -> Result<PackageIndex> {
684 get_manifest_from_disk_with_limit(cache_dir, DEFAULT_MAX_MANIFEST_DECOMPRESSED_BYTES).await
685}
686
687async fn get_manifest_from_disk_with_limit(
689 cache_dir: &Path,
690 max_decompressed: usize,
691) -> Result<PackageIndex> {
692 let path = api_manifest_path(cache_dir);
693
694 if !path.exists() {
695 return Err(Error::Cache("No cached manifest found".to_string()));
696 }
697
698 tokio::task::spawn_blocking(move || decode_cached_manifest(&path, max_decompressed))
701 .await
702 .map_err(|e| Error::Cache(format!("cache decode task panicked: {}", e)))?
703}
704
705fn is_fresh(network: DateTime<FixedOffset>, local: DateTime<FixedOffset>) -> bool {
708 network <= local
709}
710
711async fn load_cached_index(index_dir: &Path, max_decompressed: usize) -> Option<PackageIndex> {
717 match get_manifest_from_disk_with_limit(index_dir, max_decompressed).await {
718 Ok(manifest) => Some(manifest),
719 Err(err) => {
720 tracing::warn!("Cached manifest unreadable ({}); re-fetching", err);
721
722 None
723 }
724 }
725}
726
727fn decode_cached_manifest(path: &Path, max_decompressed: usize) -> Result<PackageIndex> {
736 let limit = max_decompressed as u64;
737 let file = std::fs::File::open(path)?;
738
739 let mut decoder = zstd::Decoder::new(file)?;
740 let mut decompressed_data = Vec::new();
741
742 decoder
743 .by_ref()
744 .take(limit + 1)
745 .read_to_end(&mut decompressed_data)?;
746
747 if decompressed_data.len() as u64 > limit {
748 return Err(Error::SizeLimitExceeded {
749 context: "decompressed cache".to_string(),
750 limit,
751 actual: Some(decompressed_data.len() as u64),
752 });
753 }
754
755 let config = bincode::config::standard().with_limit::<MAX_DECODE_ALLOC>();
761 let (serializable, _): (SerializedPackageIndex, usize) =
762 bincode::decode_from_slice(&decompressed_data, config)
763 .map_err(|e| Error::Cache(format!("failed to decode cached manifest: {}", e)))?;
764
765 serializable.try_into()
766}
767
768async fn get_manifest_from_network(
774 http: &Client,
775 api_url: &str,
776 max_bytes: Option<u64>,
777 retry: &RetryPolicy,
778 progress: &dyn ProgressReporter,
779) -> Result<(Vec<Package>, DateTime<FixedOffset>)> {
780 let handle = progress.begin("Downloading Api Manifest", None);
781
782 let result = retry_http(retry, || async {
785 let response = http.get(api_url).send().await?.error_for_status()?;
786 let last_modified_str = response
787 .headers()
788 .get(header::LAST_MODIFIED)
789 .ok_or(Error::MissingHeader("Last-Modified".to_string()))?
790 .to_str()?;
791 let last_modified = DateTime::parse_from_rfc2822(last_modified_str)
792 .map_err(|e| Error::InvalidHeader(format!("invalid Last-Modified header: {}", e)))?;
793
794 let raw_response_data = read_capped_body(response, max_bytes).await?;
795
796 let packages: Vec<Package> = serde_json::from_slice(&raw_response_data)?;
797
798 Ok((packages, last_modified))
799 })
800 .await;
801
802 match result {
805 Ok((packages, last_modified)) => {
806 handle.finish(Some(" Downloaded Api Manifest"));
807
808 Ok((packages, last_modified))
809 }
810 Err(err) => {
811 handle.finish(None);
812
813 Err(err)
814 }
815 }
816}
817
818async fn read_capped_body(response: reqwest::Response, max_bytes: Option<u64>) -> Result<Vec<u8>> {
822 let mut cap = max_bytes.map(|max| SizeCap::new("manifest body", max));
823
824 if let Some(cap) = &cap {
825 cap.check_declared(response.content_length())?;
826 }
827
828 let mut stream = response.bytes_stream();
829 let mut body: Vec<u8> = Vec::new();
830
831 while let Some(chunk) = stream.next().await {
832 let chunk = chunk?;
833
834 if let Some(cap) = cap.as_mut() {
835 cap.add(chunk.len() as u64)?;
836 }
837
838 body.extend_from_slice(&chunk);
839 }
840
841 Ok(body)
842}
843
844fn encode_manifest_cache(packages: Vec<Package>) -> Result<(PackageIndex, Vec<u8>)> {
850 let manifest: PackageIndex = packages.into();
851 let serializable: SerializedPackageIndex = (&manifest).into();
852
853 let binary_data = bincode::encode_to_vec(&serializable, bincode::config::standard())
854 .map_err(|e| Error::Cache(format!("failed to encode manifest cache: {}", e)))?;
855
856 Ok((manifest, binary_data))
857}
858
859async fn get_manifest_from_network_and_cache(
866 cache_dir: &Path,
867 http: &Client,
868 api_url: &str,
869 max_manifest_bytes: Option<u64>,
870 retry: &RetryPolicy,
871 progress: &dyn ProgressReporter,
872) -> Result<PackageIndex> {
873 let (packages, last_modified_from_network) =
874 get_manifest_from_network(http, api_url, max_manifest_bytes, retry, progress).await?;
875
876 let (manifest, binary_data) =
880 tokio::task::spawn_blocking(move || encode_manifest_cache(packages))
881 .await
882 .map_err(|e| Error::Cache(format!("cache encode task panicked: {}", e)))??;
883
884 write_cache_to_disk(api_manifest_path(cache_dir), &binary_data, true).await?;
885
886 if let Err(err) = write_cache_to_disk(
891 last_modified_path(cache_dir),
892 last_modified_from_network.to_rfc2822().as_bytes(),
893 false,
894 )
895 .await
896 {
897 tracing::warn!(
898 "Failed to write last_modified marker ({}); cached manifest kept, will re-fetch",
899 err
900 );
901 }
902
903 Ok(manifest)
904}
905
906async fn local_last_modified(cache_dir: &Path) -> Result<DateTime<FixedOffset>> {
908 let path = last_modified_path(cache_dir);
909
910 if let Ok(file) = fs::File::open(&path).await {
911 tracing::info!("Last modified file exists and was opened.");
912
913 let mut buf = Vec::new();
918
919 file
920 .take(MAX_LAST_MODIFIED_BYTES)
921 .read_to_end(&mut buf)
922 .await?;
923
924 let contents = String::from_utf8_lossy(&buf);
925
926 match DateTime::parse_from_rfc2822(contents.trim()) {
927 Ok(last_modified) => Ok(last_modified),
928 Err(err) => {
929 tracing::warn!(
930 "Unparseable last_modified marker ({}); treating as stale",
931 err
932 );
933
934 Ok(epoch())
935 }
936 }
937 } else {
938 tracing::info!("Last modified file does not exist and was not opened.");
939
940 Ok(epoch())
941 }
942}
943
944fn epoch() -> DateTime<FixedOffset> {
947 let dt = DateTime::from_timestamp(0, 0)
948 .expect("timestamp 0 is valid")
949 .naive_utc();
950 let offset = FixedOffset::east_opt(0).expect("zero offset is valid");
951
952 DateTime::<FixedOffset>::from_naive_utc_and_offset(dt, offset)
953}
954
955async fn network_last_modified(http: &Client, api_url: &str) -> Result<DateTime<FixedOffset>> {
957 let response = http.head(api_url).send().await?.error_for_status()?;
958
959 let last_modified = response
960 .headers()
961 .get(header::LAST_MODIFIED)
962 .ok_or(Error::MissingHeader(
963 "Last-Modified for API manifest head request".to_string(),
964 ))?;
965 let last_modified_date = DateTime::parse_from_rfc2822(last_modified.to_str()?)
966 .map_err(|e| Error::InvalidHeader(format!("invalid Last-Modified header: {}", e)))?;
967
968 Ok(last_modified_date)
969}
970
971fn api_manifest_file_exists(cache_dir: &Path) -> bool {
973 api_manifest_path(cache_dir).exists()
974}
975
976fn temp_sibling_path(path: &Path) -> PathBuf {
982 static COUNTER: AtomicU64 = AtomicU64::new(0);
983
984 let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
985 let pid = std::process::id();
986 let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("cache");
987 let tmp_name = format!(".{name}.{pid}.{seq}.tmp");
988
989 match path.parent() {
990 Some(parent) => parent.join(tmp_name),
991 None => PathBuf::from(tmp_name),
992 }
993}
994
995async fn write_cache_to_disk<T: AsRef<Path>>(
1001 path: T,
1002 contents: &[u8],
1003 use_compression: bool,
1004) -> Result<()> {
1005 let path = path.as_ref();
1006
1007 if let Some(parent) = path.parent() {
1008 fs::create_dir_all(parent).await?;
1009 }
1010
1011 let payload = if use_compression {
1012 let data = contents.to_vec();
1014
1015 tokio::task::spawn_blocking(move || zstd::encode_all(data.as_slice(), 9))
1016 .await
1017 .map_err(|e| Error::Cache(format!("cache encode task panicked: {}", e)))??
1018 } else {
1019 contents.to_vec()
1020 };
1021
1022 let tmp_path = temp_sibling_path(path);
1023
1024 if let Err(err) = write_and_rename(&tmp_path, path, &payload).await {
1027 let _ = fs::remove_file(&tmp_path).await;
1028
1029 return Err(err);
1030 }
1031
1032 Ok(())
1033}
1034
1035async fn write_and_rename(tmp_path: &Path, path: &Path, payload: &[u8]) -> Result<()> {
1039 let mut file = fs::File::create(tmp_path).await?;
1040
1041 file.write_all(payload).await?;
1042 file.flush().await?;
1043 file.sync_all().await?;
1044
1045 fs::rename(tmp_path, path).await?;
1046
1047 if let Some(parent) = path.parent()
1050 && let Ok(dir) = fs::File::open(parent).await
1051 {
1052 let _ = dir.sync_all().await;
1053 }
1054
1055 Ok(())
1056}
1057
1058fn parse_content_length(headers: &reqwest::header::HeaderMap) -> Option<u64> {
1061 headers
1062 .get(header::CONTENT_LENGTH)
1063 .and_then(|value| value.to_str().ok())
1064 .and_then(|value| value.parse::<u64>().ok())
1065}
1066
1067fn check_complete(content_length: Option<u64>, written: u64) -> Result<()> {
1074 if let Some(expected) = content_length
1075 && written != expected
1076 {
1077 return Err(Error::IncompleteDownload {
1078 expected,
1079 actual: written,
1080 });
1081 }
1082
1083 Ok(())
1084}
1085
1086fn aggregate_download_results(results: Vec<Result<()>>, total: usize) -> Result<()> {
1090 let mut failures = 0;
1091 let mut first_error: Option<String> = None;
1092
1093 for result in results {
1094 if let Err(err) = result {
1095 failures += 1;
1096
1097 tracing::error!("Download error: {:?}", err);
1098
1099 if first_error.is_none() {
1100 first_error = Some(err.to_string());
1101 }
1102 }
1103 }
1104
1105 if failures > 0 {
1106 return Err(Error::Download {
1107 failed: failures,
1108 total,
1109 first: first_error.unwrap_or_default(),
1110 });
1111 }
1112
1113 Ok(())
1114}
1115
1116async fn download_files(
1118 urls: HashMap<String, String>,
1119 cache_dir: &Path,
1120 http: &Client,
1121 concurrency: usize,
1122 max_bytes: Option<u64>,
1123 retry: RetryPolicy,
1124 progress: Arc<dyn ProgressReporter>,
1125) -> Result<()> {
1126 if urls.is_empty() {
1127 tracing::debug!("No files to download");
1128
1129 return Ok(());
1130 }
1131
1132 tracing::debug!("Processing {} mods", urls.len());
1133
1134 let total = urls.len();
1135 let concurrency = concurrency.max(1);
1136
1137 let results: Vec<Result<()>> =
1138 futures::stream::iter(urls.into_iter().map(|(archive_filename, url)| {
1139 let client = http.clone();
1140 let cache_dir = cache_dir.to_path_buf();
1141 let progress = progress.clone();
1142
1143 async move {
1144 download_file(
1145 &client,
1146 &url,
1147 &archive_filename,
1148 &cache_dir,
1149 max_bytes,
1150 &retry,
1151 progress.as_ref(),
1152 )
1153 .await
1154 }
1155 }))
1156 .buffer_unordered(concurrency)
1157 .collect()
1158 .await;
1159
1160 aggregate_download_results(results, total)
1161}
1162
1163fn safe_download_name(filename: &str) -> Result<&str> {
1167 let mut components = Path::new(filename).components();
1168 let only = components.next();
1169
1170 let is_single_normal = matches!(only, Some(std::path::Component::Normal(_)));
1171
1172 if !is_single_normal || components.next().is_some() {
1173 return Err(Error::UnsafeName(format!(
1174 "Unsafe download filename: {}",
1175 filename
1176 )));
1177 }
1178
1179 Ok(filename)
1180}
1181
1182struct TempFileGuard {
1190 path: Option<PathBuf>,
1191}
1192
1193impl TempFileGuard {
1194 fn new(path: PathBuf) -> Self {
1195 Self { path: Some(path) }
1196 }
1197
1198 fn disarm(&mut self) {
1200 self.path = None;
1201 }
1202}
1203
1204impl Drop for TempFileGuard {
1205 fn drop(&mut self) {
1206 if let Some(path) = self.path.take() {
1207 let _ = std::fs::remove_file(path);
1208 }
1209 }
1210}
1211
1212async fn download_file(
1230 client: &Client,
1231 url: &str,
1232 filename: &str,
1233 cache_dir: &Path,
1234 max_bytes: Option<u64>,
1235 retry: &RetryPolicy,
1236 progress: &dyn ProgressReporter,
1237) -> Result<()> {
1238 let downloads_directory = cache_dir.join("downloads");
1239 let file_path = downloads_directory.join(safe_download_name(filename)?);
1240
1241 tokio::fs::DirBuilder::new()
1242 .recursive(true)
1243 .create(&downloads_directory)
1244 .await
1245 .map_err(|e| {
1246 io_context(
1247 format!("creating downloads dir {}", downloads_directory.display()),
1248 e,
1249 )
1250 })?;
1251
1252 if file_path.exists() {
1253 tracing::debug!("{} already exists, skipping download", filename);
1254
1255 return Ok(());
1256 }
1257
1258 let tmp_path = temp_sibling_path(&file_path);
1259
1260 let mut tmp_guard = TempFileGuard::new(tmp_path.clone());
1263
1264 let handle = progress.begin(filename, None);
1268
1269 let attempt = retry_http(retry, || async {
1273 handle.set_position(0);
1276
1277 let response = client.get(url).send().await?.error_for_status()?;
1278
1279 let content_length = parse_content_length(response.headers());
1284 let mut cap = max_bytes.map(|max| SizeCap::new("download body", max));
1285
1286 if let Some(cap) = &cap {
1288 cap.check_declared(content_length)?;
1289 }
1290
1291 if let Some(total) = content_length {
1292 handle.set_length(total);
1293 }
1294
1295 let mut response_data = response.bytes_stream();
1296 let mut file = tokio::fs::File::create(&tmp_path).await.map_err(|e| {
1297 io_context(
1298 format!("creating temp download file {}", tmp_path.display()),
1299 e,
1300 )
1301 })?;
1302 let mut written: u64 = 0;
1303
1304 while let Some(result) = response_data.next().await {
1305 let chunk = result?;
1306
1307 written += chunk.len() as u64;
1308
1309 if let Some(cap) = cap.as_mut() {
1310 cap.add(chunk.len() as u64)?;
1311 }
1312
1313 file.write_all(&chunk).await?;
1314 handle.inc(chunk.len() as u64);
1315 }
1316
1317 file.flush().await?;
1318 file.sync_all().await?;
1319
1320 check_complete(content_length, written)
1321 })
1322 .await;
1323
1324 if let Err(err) = attempt {
1325 handle.finish(None);
1327
1328 return Err(err);
1329 }
1330
1331 if let Err(err) = tokio::fs::rename(&tmp_path, &file_path).await {
1335 handle.finish(None);
1336
1337 return Err(io_context(
1338 format!("renaming {} to {}", tmp_path.display(), file_path.display()),
1339 err,
1340 ));
1341 }
1342
1343 tmp_guard.disarm();
1345 handle.finish(None);
1346
1347 Ok(())
1348}
1349
1350#[cfg(test)]
1351mod tests {
1352 use super::*;
1353 use crate::progress::{NoopProgress, ProgressHandle};
1354 use mockito::Server;
1355 use std::fs::File;
1356 use std::io::Write;
1357 use tempfile::tempdir;
1358 use time::OffsetDateTime;
1359 use tokio::runtime::Runtime;
1360
1361 fn test_http() -> Client {
1362 Client::builder()
1363 .timeout(Duration::from_secs(60))
1364 .build()
1365 .unwrap()
1366 }
1367
1368 #[test]
1369 fn test_api_manifest_file_exists() {
1370 let temp_dir = tempdir().unwrap();
1371 let cache_dir = temp_dir.path();
1372
1373 assert!(!api_manifest_file_exists(cache_dir));
1374
1375 let _ = File::create(cache_dir.join(API_MANIFEST_FILENAME)).unwrap();
1376
1377 assert!(api_manifest_file_exists(cache_dir));
1378 }
1379
1380 #[test]
1381 fn test_path_construction() {
1382 let cache_dir = Path::new("/some/cache/dir");
1383
1384 let last_modified = last_modified_path(cache_dir);
1385 let api_manifest = api_manifest_path(cache_dir);
1386
1387 assert_eq!(last_modified, cache_dir.join(LAST_MODIFIED_FILENAME));
1388 assert_eq!(api_manifest, cache_dir.join(API_MANIFEST_FILENAME));
1389 }
1390
1391 #[test]
1392 fn test_builder_composes_url() {
1393 let temp_dir = tempdir().unwrap();
1394
1395 let client = ThunderstoreClient::builder()
1396 .base_url("https://stacklands.thunderstore.io")
1397 .community("valheim")
1398 .cache_dir(temp_dir.path())
1399 .build()
1400 .unwrap();
1401
1402 assert_eq!(
1403 client.package_index_url(),
1404 "https://stacklands.thunderstore.io/c/valheim/api/v1/package/"
1405 );
1406 }
1407
1408 #[test]
1409 fn test_builder_requires_cache_dir() {
1410 let result = ThunderstoreClient::builder().community("valheim").build();
1411
1412 assert!(matches!(result, Err(Error::Config(_))));
1413 }
1414
1415 #[test]
1416 fn parse_content_length_reads_valid_and_ignores_missing_or_bad() {
1417 use reqwest::header::{CONTENT_LENGTH, HeaderMap, HeaderValue};
1418
1419 let mut h = HeaderMap::new();
1420
1421 assert_eq!(parse_content_length(&h), None);
1422
1423 h.insert(CONTENT_LENGTH, HeaderValue::from_static("2048"));
1424
1425 assert_eq!(parse_content_length(&h), Some(2048));
1426
1427 h.insert(CONTENT_LENGTH, HeaderValue::from_static("not-a-number"));
1428
1429 assert_eq!(parse_content_length(&h), None);
1430 }
1431
1432 #[test]
1433 fn aggregate_download_results_all_ok_is_ok() {
1434 assert!(aggregate_download_results(vec![Ok(()), Ok(())], 2).is_ok());
1435 assert!(aggregate_download_results(vec![], 0).is_ok());
1436 }
1437
1438 #[test]
1439 fn aggregate_download_results_reports_count_and_first_error() {
1440 let results = vec![
1441 Ok(()),
1442 Err(Error::UnsafeName("bad".to_string())),
1443 Err(Error::IncompleteDownload {
1444 expected: 5,
1445 actual: 2,
1446 }),
1447 ];
1448
1449 let err = aggregate_download_results(results, 3).unwrap_err();
1450
1451 match err {
1452 Error::Download {
1453 failed,
1454 total,
1455 first,
1456 } => {
1457 assert_eq!(failed, 2);
1458 assert_eq!(total, 3);
1459 assert!(first.contains("unsafe name"), "first was: {first}");
1460 }
1461 other => panic!("unexpected error: {other}"),
1462 }
1463 }
1464
1465 #[test]
1466 fn compose_index_url_prefers_override_then_builds_from_community() {
1467 assert_eq!(
1468 compose_index_url("https://thunderstore.io", Some("valheim"), None).unwrap(),
1469 "https://thunderstore.io/c/valheim/api/v1/package/"
1470 );
1471
1472 assert_eq!(
1474 compose_index_url("https://thunderstore.io/", Some("valheim"), None).unwrap(),
1475 "https://thunderstore.io/c/valheim/api/v1/package/"
1476 );
1477
1478 assert_eq!(
1480 compose_index_url("https://ignored", None, Some("https://custom/pkg/")).unwrap(),
1481 "https://custom/pkg/"
1482 );
1483
1484 assert!(matches!(
1486 compose_index_url("https://thunderstore.io", None, None),
1487 Err(Error::Config(_))
1488 ));
1489
1490 assert!(matches!(
1492 compose_index_url("https://thunderstore.io", Some("bad/slug"), None),
1493 Err(Error::Config(_))
1494 ));
1495 }
1496
1497 #[test]
1498 fn resolve_decompressed_cap_precedence() {
1499 assert_eq!(resolve_decompressed_cap(Some(42), Some(100)), 42);
1501
1502 assert_eq!(resolve_decompressed_cap(None, Some(100)), 100);
1504
1505 assert_eq!(
1507 resolve_decompressed_cap(None, None),
1508 DEFAULT_MAX_MANIFEST_DECOMPRESSED_BYTES
1509 );
1510 }
1511
1512 #[test]
1513 fn is_fresh_compares_last_modified() {
1514 let older = DateTime::parse_from_rfc2822("Wed, 21 Feb 2024 15:30:45 +0000").unwrap();
1515 let newer = DateTime::parse_from_rfc2822("Thu, 22 Feb 2024 15:30:45 +0000").unwrap();
1516
1517 assert!(is_fresh(older, older));
1519 assert!(is_fresh(older, newer));
1520
1521 assert!(!is_fresh(newer, older));
1523 }
1524
1525 #[test]
1526 fn encode_manifest_cache_builds_index_and_nonempty_blob() {
1527 let (index, bytes) = encode_manifest_cache(vec![sample_package("X", "O-X", "O")]).unwrap();
1528
1529 assert_eq!(index.len(), 1);
1530 assert!(!bytes.is_empty());
1531 }
1532
1533 #[test]
1534 fn check_complete_flags_short_and_accepts_exact_or_unknown() {
1535 assert!(check_complete(Some(100), 100).is_ok());
1537
1538 assert!(check_complete(None, 42).is_ok());
1540
1541 let err = check_complete(Some(100), 50).unwrap_err();
1543
1544 assert!(matches!(
1545 err,
1546 Error::IncompleteDownload {
1547 expected: 100,
1548 actual: 50,
1549 }
1550 ));
1551 }
1552
1553 #[test]
1554 fn safe_download_name_accepts_plain_and_rejects_traversal() {
1555 assert_eq!(
1557 safe_download_name("Author-Mod-1.0.0.zip").unwrap(),
1558 "Author-Mod-1.0.0.zip"
1559 );
1560
1561 assert!(matches!(
1566 safe_download_name("../escape.zip"),
1567 Err(Error::UnsafeName(_))
1568 ));
1569 assert!(matches!(
1570 safe_download_name("/etc/passwd"),
1571 Err(Error::UnsafeName(_))
1572 ));
1573 assert!(matches!(
1574 safe_download_name("a/b.zip"),
1575 Err(Error::UnsafeName(_))
1576 ));
1577 }
1578
1579 #[test]
1580 fn test_write_cache_to_disk() {
1581 let temp_dir = tempdir().unwrap();
1582 let cache_path = temp_dir.path().join("test_cache.txt");
1583 let test_data = b"Test cache data";
1584
1585 let rt = Runtime::new().unwrap();
1586 let result = rt.block_on(write_cache_to_disk(&cache_path, test_data, false));
1587
1588 assert!(result.is_ok());
1589 assert!(cache_path.exists());
1590
1591 let content = std::fs::read(&cache_path).unwrap();
1592 assert_eq!(content, test_data);
1593
1594 let new_data = b"Updated cache data";
1595 let result = rt.block_on(write_cache_to_disk(&cache_path, new_data, false));
1596
1597 assert!(result.is_ok());
1598 let content = std::fs::read(&cache_path).unwrap();
1599
1600 assert_eq!(content, new_data);
1601 }
1602
1603 #[test]
1604 fn test_write_cache_to_disk_creates_directories() {
1605 let temp_dir = tempdir().unwrap();
1606
1607 let nonexistent_subdir = temp_dir.path().join("subdir1/subdir2");
1608 let cache_path = nonexistent_subdir.join("test_cache.txt");
1609
1610 assert!(!nonexistent_subdir.exists());
1611 assert!(!cache_path.exists());
1612
1613 let test_data = b"Test cache data";
1614 let rt = Runtime::new().unwrap();
1615
1616 let result = rt.block_on(write_cache_to_disk(&cache_path, test_data, false));
1617 assert!(result.is_ok());
1618
1619 assert!(
1620 nonexistent_subdir.exists(),
1621 "Directory structure should be created"
1622 );
1623 assert!(cache_path.exists(), "File should be created");
1624
1625 let content = std::fs::read(&cache_path).unwrap();
1626 assert_eq!(content, test_data, "File should contain the expected data");
1627 }
1628
1629 #[test]
1630 fn test_write_cache_to_disk_with_compression() {
1631 let temp_dir = tempdir().unwrap();
1632 let api_manifest_path = temp_dir.path().join("test_compressed.bin");
1633
1634 let package = sample_package("TestMod", "TestOwner-TestMod", "TestOwner");
1635
1636 let packages = vec![package];
1637 let binary_data =
1638 bincode::serde::encode_to_vec(&packages, bincode::config::standard()).unwrap();
1639
1640 let rt = Runtime::new().unwrap();
1641 let result = rt.block_on(write_cache_to_disk(&api_manifest_path, &binary_data, true));
1642
1643 assert!(result.is_ok());
1644 assert!(api_manifest_path.exists());
1645
1646 let compressed_data = std::fs::read(&api_manifest_path).unwrap();
1647 let decompressed_data = zstd::decode_all(compressed_data.as_slice()).unwrap();
1648 let (decoded, _): (Vec<Package>, usize) =
1649 bincode::serde::decode_from_slice(&decompressed_data, bincode::config::standard()).unwrap();
1650
1651 assert_eq!(decoded.len(), 1);
1652 assert_eq!(decoded[0].full_name, Some("TestOwner-TestMod".to_string()));
1653 }
1654
1655 #[test]
1656 fn test_local_last_modified() {
1657 let temp_dir = tempdir().unwrap();
1658 let cache_dir = temp_dir.path();
1659 let rt = Runtime::new().unwrap();
1660
1661 {
1662 let result = rt.block_on(local_last_modified(cache_dir));
1663
1664 assert!(result.is_ok());
1665 }
1666
1667 let test_date = "Wed, 21 Feb 2024 15:30:45 GMT";
1668 let mut file = File::create(cache_dir.join(LAST_MODIFIED_FILENAME)).unwrap();
1669 file.write_all(test_date.as_bytes()).unwrap();
1670
1671 {
1672 let result = rt.block_on(local_last_modified(cache_dir));
1673
1674 assert!(result.is_ok());
1675 }
1676 }
1677
1678 #[test]
1679 fn test_get_manifest_from_disk() {
1680 let temp_dir = tempdir().unwrap();
1681 let cache_dir = temp_dir.path();
1682
1683 let package = sample_package("ModA", "Owner-ModA", "Owner");
1684
1685 let packages = vec![package];
1686 let interned_manifest: PackageIndex = packages.into();
1687 let serializable: SerializedPackageIndex = (&interned_manifest).into();
1688 let binary_data = bincode::encode_to_vec(&serializable, bincode::config::standard()).unwrap();
1689 let compressed_data = zstd::encode_all(binary_data.as_slice(), 9).unwrap();
1690 let mut file = File::create(cache_dir.join(API_MANIFEST_FILENAME)).unwrap();
1691 file.write_all(&compressed_data).unwrap();
1692
1693 let rt = Runtime::new().unwrap();
1694 let manifest = rt.block_on(get_manifest_from_disk(cache_dir)).unwrap();
1695
1696 assert_eq!(manifest.len(), 1);
1697 assert_eq!(
1698 manifest.resolve_full_name_at(0),
1699 Some("Owner-ModA".to_string())
1700 );
1701 }
1702
1703 #[test]
1704 fn test_get_manifest_from_disk_error() {
1705 let temp_dir = tempdir().unwrap();
1706 let cache_dir = temp_dir.path();
1707
1708 let mut file = File::create(cache_dir.join(API_MANIFEST_FILENAME)).unwrap();
1709 file
1710 .write_all(b"This is not valid compressed data")
1711 .unwrap();
1712
1713 let rt = Runtime::new().unwrap();
1714 let result = rt.block_on(get_manifest_from_disk(cache_dir));
1715
1716 assert!(result.is_err());
1717 }
1718
1719 #[test]
1720 fn test_network_last_modified() {
1721 let mut server = Server::new();
1722 let test_date = "Wed, 21 Feb 2024 15:30:45 GMT";
1723 let mock = server
1724 .mock("HEAD", "/c/valheim/api/v1/package/")
1725 .with_status(200)
1726 .with_header("Last-Modified", test_date)
1727 .create();
1728 let api_url = format!("{}/c/valheim/api/v1/package/", server.url());
1729 let rt = Runtime::new().unwrap();
1730
1731 let result = rt.block_on(network_last_modified(&test_http(), &api_url));
1732
1733 assert!(result.is_ok());
1734 if let Ok(parsed_date) = result {
1735 let expected_date = DateTime::parse_from_rfc2822(test_date).unwrap();
1736 assert_eq!(parsed_date, expected_date);
1737 }
1738
1739 mock.assert();
1740 }
1741
1742 #[test]
1743 fn test_get_manifest_from_network() {
1744 let mut server = Server::new();
1745 let test_date = "Wed, 21 Feb 2024 15:30:45 GMT";
1746
1747 let mock = server
1748 .mock("GET", "/c/valheim/api/v1/package/")
1749 .with_status(200)
1750 .with_header("Content-Type", "application/json")
1751 .with_header("Last-Modified", test_date)
1752 .with_body(sample_manifest_json())
1753 .create();
1754
1755 let api_url = format!("{}/c/valheim/api/v1/package/", server.url());
1756
1757 let rt = Runtime::new().unwrap();
1758 let result = rt.block_on(get_manifest_from_network(
1759 &test_http(),
1760 &api_url,
1761 None,
1762 &RetryPolicy::none(),
1763 &NoopProgress,
1764 ));
1765
1766 assert!(result.is_ok());
1767 if let Ok((packages, last_modified)) = result {
1768 assert_eq!(packages.len(), 1);
1769 assert_eq!(packages[0].full_name, Some("Owner-ModA".to_string()));
1770 let expected_date = DateTime::parse_from_rfc2822(test_date).unwrap();
1771 assert_eq!(last_modified, expected_date);
1772 }
1773
1774 mock.assert();
1775 }
1776
1777 fn seed_index_cache(index_dir: &Path, last_modified: &str) {
1779 std::fs::create_dir_all(index_dir).unwrap();
1780
1781 let package = sample_package("CachedMod", "CachedOwner-CachedMod", "CachedOwner");
1782 let interned_manifest: PackageIndex = vec![package].into();
1783 let serializable: SerializedPackageIndex = (&interned_manifest).into();
1784 let binary_data = bincode::encode_to_vec(&serializable, bincode::config::standard()).unwrap();
1785 let compressed_data = zstd::encode_all(binary_data.as_slice(), 9).unwrap();
1786
1787 File::create(index_dir.join(API_MANIFEST_FILENAME))
1788 .unwrap()
1789 .write_all(&compressed_data)
1790 .unwrap();
1791
1792 File::create(index_dir.join(LAST_MODIFIED_FILENAME))
1793 .unwrap()
1794 .write_all(last_modified.as_bytes())
1795 .unwrap();
1796 }
1797
1798 #[test]
1799 fn test_get_manifest_loads_fresh_cache() {
1800 let mut server = Server::new();
1801 let temp_dir = tempdir().unwrap();
1802 let cache_dir = temp_dir.path();
1803
1804 let mock = server
1806 .mock("HEAD", "/pkg/")
1807 .with_status(200)
1808 .with_header("Last-Modified", "Wed, 21 Feb 2024 15:30:45 GMT")
1809 .create();
1810 let api_url = format!("{}/pkg/", server.url());
1811
1812 let client = ThunderstoreClient::builder()
1813 .package_index_url(&api_url)
1814 .cache_dir(cache_dir)
1815 .build()
1816 .unwrap();
1817
1818 let now = chrono::Utc::now().with_timezone(&chrono::FixedOffset::east_opt(0).unwrap());
1819 seed_index_cache(&client.index_cache_dir(), &now.to_rfc2822());
1820
1821 let rt = Runtime::new().unwrap();
1822 let manifest = rt.block_on(client.get_manifest()).unwrap();
1823
1824 assert_eq!(manifest.len(), 1);
1825 assert_eq!(
1826 manifest.resolve_full_name_at(0),
1827 Some("CachedOwner-CachedMod".to_string())
1828 );
1829
1830 mock.assert();
1831 }
1832
1833 #[test]
1834 fn test_get_manifest_falls_back_to_cache_when_head_fails() {
1835 let mut server = Server::new();
1836 let temp_dir = tempdir().unwrap();
1837 let cache_dir = temp_dir.path();
1838
1839 let mock = server
1841 .mock("HEAD", "/c/valheim/api/v1/package/")
1842 .with_status(500)
1843 .create();
1844 let api_url = format!("{}/c/valheim/api/v1/package/", server.url());
1845
1846 let client = ThunderstoreClient::builder()
1847 .package_index_url(&api_url)
1848 .cache_dir(cache_dir)
1849 .build()
1850 .unwrap();
1851
1852 seed_index_cache(&client.index_cache_dir(), "Wed, 21 Feb 2024 15:30:45 GMT");
1853
1854 let rt = Runtime::new().unwrap();
1855 let result = rt.block_on(client.get_manifest());
1856
1857 assert!(result.is_ok());
1858
1859 let manifest = result.unwrap();
1860
1861 assert_eq!(manifest.len(), 1);
1862 assert_eq!(
1863 manifest.resolve_full_name_at(0),
1864 Some("CachedOwner-CachedMod".to_string())
1865 );
1866
1867 mock.assert();
1868 }
1869
1870 #[test]
1871 fn test_refresh_index_bypasses_cache_and_downloads() {
1872 let mut server = Server::new();
1873 let temp_dir = tempdir().unwrap();
1874 let cache_dir = temp_dir.path();
1875
1876 let get_mock = server
1878 .mock("GET", "/pkg/")
1879 .with_status(200)
1880 .with_header("Content-Type", "application/json")
1881 .with_header("Last-Modified", "Wed, 21 Feb 2024 15:30:45 GMT")
1882 .with_body(sample_manifest_json())
1883 .create();
1884 let api_url = format!("{}/pkg/", server.url());
1885
1886 let client = ThunderstoreClient::builder()
1887 .package_index_url(&api_url)
1888 .cache_dir(cache_dir)
1889 .build()
1890 .unwrap();
1891
1892 let now = chrono::Utc::now().with_timezone(&chrono::FixedOffset::east_opt(0).unwrap());
1894 seed_index_cache(&client.index_cache_dir(), &now.to_rfc2822());
1895
1896 let rt = Runtime::new().unwrap();
1897 let manifest = rt.block_on(client.refresh_index()).unwrap();
1898
1899 assert_eq!(
1901 manifest.resolve_full_name_at(0),
1902 Some("Owner-ModA".to_string())
1903 );
1904
1905 get_mock.assert();
1906 }
1907
1908 #[test]
1909 fn test_get_manifest_downloads_when_cache_is_stale() {
1910 let mut server = Server::new();
1911 let temp_dir = tempdir().unwrap();
1912 let cache_dir = temp_dir.path();
1913
1914 let head_mock = server
1916 .mock("HEAD", "/pkg/")
1917 .with_status(200)
1918 .with_header("Last-Modified", "Wed, 28 Feb 2024 15:30:45 GMT")
1919 .create();
1920 let get_mock = server
1921 .mock("GET", "/pkg/")
1922 .with_status(200)
1923 .with_header("Content-Type", "application/json")
1924 .with_header("Last-Modified", "Wed, 28 Feb 2024 15:30:45 GMT")
1925 .with_body(sample_manifest_json())
1926 .create();
1927 let api_url = format!("{}/pkg/", server.url());
1928
1929 let client = ThunderstoreClient::builder()
1930 .package_index_url(&api_url)
1931 .cache_dir(cache_dir)
1932 .build()
1933 .unwrap();
1934
1935 seed_index_cache(&client.index_cache_dir(), "Wed, 21 Feb 2024 15:30:45 GMT");
1936
1937 let rt = Runtime::new().unwrap();
1938 let manifest = rt.block_on(client.get_manifest()).unwrap();
1939
1940 assert_eq!(
1942 manifest.resolve_full_name_at(0),
1943 Some("Owner-ModA".to_string())
1944 );
1945
1946 head_mock.assert();
1947 get_mock.assert();
1948 }
1949
1950 #[test]
1951 fn test_download_file_skips_existing_without_refetch() {
1952 let mut server = Server::new();
1953 let temp_dir = tempdir().unwrap();
1954 let cache_dir = temp_dir.path();
1955
1956 let mock = server
1959 .mock("GET", "/mod.zip")
1960 .with_status(200)
1961 .with_body("zip-bytes")
1962 .expect(1)
1963 .create();
1964 let file_url = format!("{}/mod.zip", server.url());
1965
1966 let rt = Runtime::new().unwrap();
1967
1968 rt.block_on(download_file(
1969 &test_http(),
1970 &file_url,
1971 "mod.zip",
1972 cache_dir,
1973 None,
1974 &RetryPolicy::none(),
1975 &NoopProgress,
1976 ))
1977 .unwrap();
1978
1979 rt.block_on(download_file(
1981 &test_http(),
1982 &file_url,
1983 "mod.zip",
1984 cache_dir,
1985 None,
1986 &RetryPolicy::none(),
1987 &NoopProgress,
1988 ))
1989 .unwrap();
1990
1991 mock.assert();
1992 }
1993
1994 #[test]
1995 fn test_head_error_status_is_not_trusted_as_freshness() {
1996 let mut server = Server::new();
1997 let temp_dir = tempdir().unwrap();
1998 let cache_dir = temp_dir.path();
1999
2000 let mock = server
2003 .mock("HEAD", "/pkg/")
2004 .with_status(500)
2005 .with_header("Last-Modified", "Wed, 21 Feb 2099 15:30:45 GMT")
2006 .create();
2007 let api_url = format!("{}/pkg/", server.url());
2008
2009 let client = ThunderstoreClient::builder()
2010 .package_index_url(&api_url)
2011 .cache_dir(cache_dir)
2012 .build()
2013 .unwrap();
2014
2015 seed_index_cache(&client.index_cache_dir(), "Wed, 21 Feb 2024 15:30:45 GMT");
2016
2017 let rt = Runtime::new().unwrap();
2018 let manifest = rt.block_on(client.get_manifest()).unwrap();
2019
2020 assert_eq!(manifest.len(), 1);
2021
2022 mock.assert();
2023 }
2024
2025 #[test]
2026 fn test_get_manifest_refetches_on_unsupported_cache_version() {
2027 let mut server = Server::new();
2028 let temp_dir = tempdir().unwrap();
2029 let cache_dir = temp_dir.path();
2030
2031 let test_date = "Wed, 21 Feb 2024 15:30:45 GMT";
2035 let head = server
2036 .mock("HEAD", "/pkg/")
2037 .with_status(200)
2038 .with_header("Last-Modified", test_date)
2039 .create();
2040 let get = server
2041 .mock("GET", "/pkg/")
2042 .with_status(200)
2043 .with_header("Content-Type", "application/json")
2044 .with_header("Last-Modified", test_date)
2045 .with_body(sample_manifest_json())
2046 .create();
2047 let api_url = format!("{}/pkg/", server.url());
2048
2049 let client = ThunderstoreClient::builder()
2050 .package_index_url(&api_url)
2051 .cache_dir(cache_dir)
2052 .build()
2053 .unwrap();
2054
2055 let index_dir = client.index_cache_dir();
2057 std::fs::create_dir_all(&index_dir).unwrap();
2058
2059 let interned: PackageIndex = vec![sample_package("X", "O-X", "O")].into();
2060 let mut serializable: SerializedPackageIndex = (&interned).into();
2061 serializable.format_version = 99;
2062 let binary_data = bincode::encode_to_vec(&serializable, bincode::config::standard()).unwrap();
2063 let compressed = zstd::encode_all(binary_data.as_slice(), 9).unwrap();
2064
2065 File::create(index_dir.join(API_MANIFEST_FILENAME))
2066 .unwrap()
2067 .write_all(&compressed)
2068 .unwrap();
2069
2070 let now = chrono::Utc::now().with_timezone(&chrono::FixedOffset::east_opt(0).unwrap());
2071
2072 File::create(index_dir.join(LAST_MODIFIED_FILENAME))
2073 .unwrap()
2074 .write_all(now.to_rfc2822().as_bytes())
2075 .unwrap();
2076
2077 let rt = Runtime::new().unwrap();
2078 let manifest = rt.block_on(client.get_manifest()).unwrap();
2079
2080 assert_eq!(
2082 manifest.resolve_full_name_at(0),
2083 Some("Owner-ModA".to_string())
2084 );
2085
2086 head.assert();
2087 get.assert();
2088 }
2089
2090 #[test]
2091 fn test_builder_rejects_invalid_community() {
2092 let temp_dir = tempdir().unwrap();
2093
2094 let result = ThunderstoreClient::builder()
2095 .community("../../etc")
2096 .cache_dir(temp_dir.path())
2097 .build();
2098
2099 assert!(matches!(result, Err(Error::Config(_))));
2100 }
2101
2102 #[test]
2103 fn test_index_cache_dir_is_namespaced_per_community() {
2104 let temp_dir = tempdir().unwrap();
2105 let cache_dir = temp_dir.path();
2106
2107 let a = ThunderstoreClient::builder()
2108 .community("valheim")
2109 .cache_dir(cache_dir)
2110 .build()
2111 .unwrap();
2112 let b = ThunderstoreClient::builder()
2113 .community("lethal-company")
2114 .cache_dir(cache_dir)
2115 .build()
2116 .unwrap();
2117
2118 assert_ne!(a.index_cache_dir(), b.index_cache_dir());
2119 assert!(a.index_cache_dir().starts_with(cache_dir));
2120 assert!(b.index_cache_dir().starts_with(cache_dir));
2121 }
2122
2123 #[test]
2124 fn test_local_last_modified_treats_corrupt_marker_as_stale() {
2125 let temp_dir = tempdir().unwrap();
2126 let cache_dir = temp_dir.path();
2127
2128 File::create(cache_dir.join(LAST_MODIFIED_FILENAME))
2129 .unwrap()
2130 .write_all(b"not a date")
2131 .unwrap();
2132
2133 let rt = Runtime::new().unwrap();
2134 let result = rt.block_on(local_last_modified(cache_dir)).unwrap();
2135
2136 assert_eq!(result, epoch());
2137 }
2138
2139 #[test]
2140 fn test_get_manifest_from_network_rejects_oversize_body() {
2141 let mut server = Server::new();
2142 let test_date = "Wed, 21 Feb 2024 15:30:45 GMT";
2143
2144 let mock = server
2145 .mock("GET", "/pkg/")
2146 .with_status(200)
2147 .with_header("Last-Modified", test_date)
2148 .with_body(sample_manifest_json())
2149 .create();
2150 let api_url = format!("{}/pkg/", server.url());
2151
2152 let rt = Runtime::new().unwrap();
2153 let result = rt.block_on(get_manifest_from_network(
2154 &test_http(),
2155 &api_url,
2156 Some(8),
2157 &RetryPolicy::none(),
2158 &NoopProgress,
2159 ));
2160
2161 assert!(matches!(result, Err(Error::SizeLimitExceeded { .. })));
2162
2163 mock.assert();
2164 }
2165
2166 #[test]
2167 fn test_get_manifest_from_disk_rejects_oversized_decompressed() {
2168 let temp_dir = tempdir().unwrap();
2169 let cache_dir = temp_dir.path();
2170
2171 let interned: PackageIndex = vec![sample_package("X", "O-X", "O")].into();
2172 let serializable: SerializedPackageIndex = (&interned).into();
2173 let binary_data = bincode::encode_to_vec(&serializable, bincode::config::standard()).unwrap();
2174 let compressed = zstd::encode_all(binary_data.as_slice(), 9).unwrap();
2175
2176 File::create(cache_dir.join(API_MANIFEST_FILENAME))
2177 .unwrap()
2178 .write_all(&compressed)
2179 .unwrap();
2180
2181 let rt = Runtime::new().unwrap();
2182 let result = rt.block_on(get_manifest_from_disk_with_limit(cache_dir, 4));
2183
2184 assert!(matches!(result, Err(Error::SizeLimitExceeded { .. })));
2185 }
2186
2187 #[test]
2188 fn test_read_cap_defaults_to_download_cap() {
2189 let temp_dir = tempdir().unwrap();
2190
2191 let client = ThunderstoreClient::builder()
2194 .community("valheim")
2195 .cache_dir(temp_dir.path())
2196 .build()
2197 .unwrap();
2198
2199 assert_eq!(
2200 client.max_manifest_decompressed_bytes as u64,
2201 DEFAULT_MAX_MANIFEST_BYTES
2202 );
2203
2204 let client = ThunderstoreClient::builder()
2206 .community("valheim")
2207 .cache_dir(temp_dir.path())
2208 .manifest_max_bytes(900 * 1024 * 1024)
2209 .build()
2210 .unwrap();
2211
2212 assert_eq!(
2213 client.max_manifest_decompressed_bytes as u64,
2214 900 * 1024 * 1024
2215 );
2216
2217 let client = ThunderstoreClient::builder()
2220 .community("valheim")
2221 .cache_dir(temp_dir.path())
2222 .manifest_max_bytes(900 * 1024 * 1024)
2223 .manifest_max_decompressed_bytes(64 * 1024 * 1024)
2224 .build()
2225 .unwrap();
2226
2227 assert_eq!(client.max_manifest_decompressed_bytes, 64 * 1024 * 1024);
2228 }
2229
2230 #[test]
2231 fn test_large_cache_roundtrips_within_cap_and_rejects_below() {
2232 let temp_dir = tempdir().unwrap();
2233 let cache_dir = temp_dir.path();
2234
2235 let packages: Vec<Package> = (0..500)
2239 .map(|i| {
2240 sample_package(
2241 &format!("Mod{i}"),
2242 &format!("Owner{i}-Mod{i}"),
2243 &format!("Owner{i}"),
2244 )
2245 })
2246 .collect();
2247 let count = packages.len();
2248
2249 let interned: PackageIndex = packages.into();
2250 let serializable: SerializedPackageIndex = (&interned).into();
2251 let binary_data = bincode::encode_to_vec(&serializable, bincode::config::standard()).unwrap();
2252 let decompressed_len = binary_data.len();
2253 let compressed = zstd::encode_all(binary_data.as_slice(), 9).unwrap();
2254
2255 File::create(cache_dir.join(API_MANIFEST_FILENAME))
2256 .unwrap()
2257 .write_all(&compressed)
2258 .unwrap();
2259
2260 let rt = Runtime::new().unwrap();
2261
2262 let manifest = rt
2264 .block_on(get_manifest_from_disk_with_limit(
2265 cache_dir,
2266 decompressed_len,
2267 ))
2268 .unwrap();
2269
2270 assert_eq!(manifest.len(), count);
2271
2272 assert!(decompressed_len < DEFAULT_MAX_MANIFEST_DECOMPRESSED_BYTES);
2275
2276 let manifest = rt
2277 .block_on(get_manifest_from_disk_with_limit(
2278 cache_dir,
2279 DEFAULT_MAX_MANIFEST_DECOMPRESSED_BYTES,
2280 ))
2281 .unwrap();
2282
2283 assert_eq!(manifest.len(), count);
2284
2285 let result = rt.block_on(get_manifest_from_disk_with_limit(
2288 cache_dir,
2289 decompressed_len - 1,
2290 ));
2291
2292 assert!(matches!(result, Err(Error::SizeLimitExceeded { .. })));
2293 }
2294
2295 #[test]
2296 fn test_get_manifest_from_network_and_cache() {
2297 let mut server = Server::new();
2298 let test_date = "Wed, 21 Feb 2024 15:30:45 GMT";
2299
2300 let mock = server
2301 .mock("GET", "/c/valheim/api/v1/package/")
2302 .with_status(200)
2303 .with_header("Content-Type", "application/json")
2304 .with_header("Last-Modified", test_date)
2305 .with_body(sample_manifest_json())
2306 .create();
2307
2308 let api_url = format!("{}/c/valheim/api/v1/package/", server.url());
2309 let temp_dir = tempdir().unwrap();
2310 let cache_dir = temp_dir.path();
2311
2312 let rt = Runtime::new().unwrap();
2313 let result = rt.block_on(get_manifest_from_network_and_cache(
2314 cache_dir,
2315 &test_http(),
2316 &api_url,
2317 None,
2318 &RetryPolicy::none(),
2319 &NoopProgress,
2320 ));
2321
2322 assert!(result.is_ok());
2323 if let Ok(manifest) = result {
2324 assert_eq!(manifest.len(), 1);
2325 assert_eq!(
2326 manifest.resolve_full_name_at(0),
2327 Some("Owner-ModA".to_string())
2328 );
2329
2330 assert!(last_modified_path(cache_dir).exists());
2331 assert!(api_manifest_path(cache_dir).exists());
2332
2333 let last_mod_content = std::fs::read_to_string(last_modified_path(cache_dir)).unwrap();
2334 assert!(last_mod_content.contains("21 Feb 2024 15:30:45"));
2335 }
2336
2337 mock.assert();
2338 }
2339
2340 #[test]
2341 fn test_download_file() {
2342 let mut server = Server::new();
2343 let temp_dir = tempdir().unwrap();
2344 let cache_dir = temp_dir.path();
2345
2346 let test_data = b"This is test file content";
2347 let content_length = test_data.len();
2348
2349 let _mock = server
2350 .mock("GET", "/test-file.zip")
2351 .with_status(200)
2352 .with_header("Content-Type", "application/zip")
2353 .with_header("Content-Length", &content_length.to_string())
2354 .with_body(test_data)
2355 .create();
2356
2357 let file_url = format!("{}/test-file.zip", server.url());
2358 let filename = "test-file.zip".to_string();
2359
2360 let rt = Runtime::new().unwrap();
2361 let client = test_http();
2362
2363 let result = rt.block_on(download_file(
2364 &client,
2365 &file_url,
2366 &filename,
2367 cache_dir,
2368 None,
2369 &RetryPolicy::none(),
2370 &NoopProgress,
2371 ));
2372
2373 assert!(result.is_ok());
2374
2375 let downloads_dir = cache_dir.join("downloads");
2376 assert!(downloads_dir.exists());
2377
2378 let downloaded_file = downloads_dir.join(&filename);
2379 assert!(downloaded_file.exists());
2380
2381 let file_content = std::fs::read(&downloaded_file).unwrap();
2382 assert_eq!(file_content, test_data);
2383
2384 let result2 = rt.block_on(download_file(
2385 &client,
2386 &file_url,
2387 &filename,
2388 cache_dir,
2389 None,
2390 &RetryPolicy::none(),
2391 &NoopProgress,
2392 ));
2393
2394 assert!(result2.is_ok());
2395 }
2396
2397 #[test]
2398 fn test_download_file_without_content_length_succeeds() {
2399 let mut server = Server::new();
2400 let temp_dir = tempdir().unwrap();
2401 let cache_dir = temp_dir.path();
2402
2403 let test_data = b"This is test file content";
2404
2405 let mock = server
2408 .mock("GET", "/test-file.zip")
2409 .with_status(200)
2410 .with_header("Content-Type", "application/zip")
2411 .with_body(test_data)
2412 .create();
2413
2414 let file_url = format!("{}/test-file.zip", server.url());
2415 let filename = "test-file-no-header.zip".to_string();
2416
2417 let rt = Runtime::new().unwrap();
2418 let client = test_http();
2419
2420 let result = rt.block_on(download_file(
2421 &client,
2422 &file_url,
2423 &filename,
2424 cache_dir,
2425 None,
2426 &RetryPolicy::none(),
2427 &NoopProgress,
2428 ));
2429
2430 assert!(result.is_ok());
2431
2432 let downloaded = cache_dir.join("downloads").join(&filename);
2433
2434 assert!(downloaded.exists());
2435 assert_eq!(std::fs::read(&downloaded).unwrap(), test_data);
2436
2437 mock.assert();
2438 }
2439
2440 #[test]
2441 fn test_download_files() {
2442 let mut server = Server::new();
2443 let temp_dir = tempdir().unwrap();
2444 let cache_dir = temp_dir.path();
2445
2446 let test_data1 = b"This is test file 1 content";
2447 let content_length1 = test_data1.len();
2448
2449 let test_data2 = b"This is test file 2 content - longer content";
2450 let content_length2 = test_data2.len();
2451
2452 let mock1 = server
2453 .mock("GET", "/file1.zip")
2454 .with_status(200)
2455 .with_header("Content-Type", "application/zip")
2456 .with_header("Content-Length", &content_length1.to_string())
2457 .with_body(test_data1)
2458 .create();
2459
2460 let mock2 = server
2461 .mock("GET", "/file2.zip")
2462 .with_status(200)
2463 .with_header("Content-Type", "application/zip")
2464 .with_header("Content-Length", &content_length2.to_string())
2465 .with_body(test_data2)
2466 .create();
2467
2468 let mut urls = HashMap::new();
2469 urls.insert(
2470 "file1.zip".to_string(),
2471 format!("{}/file1.zip", server.url()),
2472 );
2473 urls.insert(
2474 "file2.zip".to_string(),
2475 format!("{}/file2.zip", server.url()),
2476 );
2477
2478 let rt = Runtime::new().unwrap();
2479 let result = rt.block_on(download_files(
2480 urls,
2481 cache_dir,
2482 &test_http(),
2483 2,
2484 None,
2485 RetryPolicy::none(),
2486 Arc::new(NoopProgress),
2487 ));
2488
2489 assert!(result.is_ok());
2490
2491 let downloads_dir = cache_dir.join("downloads");
2492 assert!(downloads_dir.exists());
2493
2494 let file1 = downloads_dir.join("file1.zip");
2495 let file2 = downloads_dir.join("file2.zip");
2496 assert!(file1.exists());
2497 assert!(file2.exists());
2498
2499 let file1_content = std::fs::read(&file1).unwrap();
2500 let file2_content = std::fs::read(&file2).unwrap();
2501 assert_eq!(file1_content, test_data1);
2502 assert_eq!(file2_content, test_data2);
2503
2504 mock1.assert();
2505 mock2.assert();
2506 }
2507
2508 #[test]
2509 fn test_download_files_empty() {
2510 let temp_dir = tempdir().unwrap();
2511 let cache_dir = temp_dir.path();
2512
2513 let urls = HashMap::new();
2514
2515 let rt = Runtime::new().unwrap();
2516 let result = rt.block_on(download_files(
2517 urls,
2518 cache_dir,
2519 &test_http(),
2520 2,
2521 None,
2522 RetryPolicy::none(),
2523 Arc::new(NoopProgress),
2524 ));
2525
2526 assert!(result.is_ok());
2527 }
2528
2529 #[test]
2530 fn test_download_files_reports_failure() {
2531 let mut server = Server::new();
2532 let temp_dir = tempdir().unwrap();
2533 let cache_dir = temp_dir.path();
2534
2535 let mock = server.mock("GET", "/missing.zip").with_status(500).create();
2536
2537 let mut urls = HashMap::new();
2538 urls.insert(
2539 "missing.zip".to_string(),
2540 format!("{}/missing.zip", server.url()),
2541 );
2542
2543 let rt = Runtime::new().unwrap();
2544 let result = rt.block_on(download_files(
2545 urls,
2546 cache_dir,
2547 &test_http(),
2548 2,
2549 None,
2550 RetryPolicy::none(),
2551 Arc::new(NoopProgress),
2552 ));
2553
2554 assert!(result.is_err());
2555
2556 mock.assert();
2557 }
2558
2559 #[test]
2560 fn test_download_file_rejects_unsafe_filename() {
2561 let temp_dir = tempdir().unwrap();
2562 let cache_dir = temp_dir.path();
2563
2564 let rt = Runtime::new().unwrap();
2565 let result = rt.block_on(download_file(
2566 &test_http(),
2567 "http://example.invalid/x.zip",
2568 "../escape.zip",
2569 cache_dir,
2570 None,
2571 &RetryPolicy::none(),
2572 &NoopProgress,
2573 ));
2574
2575 assert!(result.is_err());
2576 }
2577
2578 #[test]
2579 fn test_download_file_rejects_declared_oversize() {
2580 let mut server = Server::new();
2581 let temp_dir = tempdir().unwrap();
2582 let cache_dir = temp_dir.path();
2583
2584 let body = vec![0u8; 100];
2587 let mock = server
2588 .mock("GET", "/big.zip")
2589 .with_status(200)
2590 .with_header("Content-Length", "100")
2591 .with_body(&body)
2592 .create();
2593
2594 let file_url = format!("{}/big.zip", server.url());
2595
2596 let rt = Runtime::new().unwrap();
2597 let result = rt.block_on(download_file(
2598 &test_http(),
2599 &file_url,
2600 "big.zip",
2601 cache_dir,
2602 Some(8),
2603 &RetryPolicy::none(),
2604 &NoopProgress,
2605 ));
2606
2607 assert!(matches!(result, Err(Error::SizeLimitExceeded { .. })));
2608 assert!(!cache_dir.join("downloads").join("big.zip").exists());
2609
2610 mock.assert();
2611 }
2612
2613 #[test]
2614 fn test_download_file_rejects_streamed_oversize() {
2615 let mut server = Server::new();
2616 let temp_dir = tempdir().unwrap();
2617 let cache_dir = temp_dir.path();
2618
2619 let mock = server
2622 .mock("GET", "/stream.zip")
2623 .with_status(200)
2624 .with_chunked_body(|w| w.write_all(&[0u8; 100]))
2625 .create();
2626
2627 let file_url = format!("{}/stream.zip", server.url());
2628
2629 let rt = Runtime::new().unwrap();
2630 let result = rt.block_on(download_file(
2631 &test_http(),
2632 &file_url,
2633 "stream.zip",
2634 cache_dir,
2635 Some(8),
2636 &RetryPolicy::none(),
2637 &NoopProgress,
2638 ));
2639
2640 assert!(matches!(result, Err(Error::SizeLimitExceeded { .. })));
2641
2642 let downloads_dir = cache_dir.join("downloads");
2643 assert!(!downloads_dir.join("stream.zip").exists());
2644
2645 let leftover = std::fs::read_dir(&downloads_dir)
2646 .unwrap()
2647 .filter_map(|e| e.ok())
2648 .any(|e| e.file_name().to_string_lossy().contains(".tmp"));
2649
2650 assert!(!leftover, "no .tmp temp files should remain");
2651
2652 mock.assert();
2653 }
2654
2655 fn fast_retry(max_retries: u32) -> RetryPolicy {
2656 RetryPolicy {
2657 max_retries,
2658 base_delay: Duration::ZERO,
2659 max_delay: Duration::ZERO,
2660 }
2661 }
2662
2663 #[test]
2664 fn test_download_file_retries_transient_5xx() {
2665 let mut server = Server::new();
2666 let temp_dir = tempdir().unwrap();
2667 let cache_dir = temp_dir.path();
2668
2669 let mock = server
2671 .mock("GET", "/flaky.zip")
2672 .with_status(500)
2673 .expect(3)
2674 .create();
2675 let file_url = format!("{}/flaky.zip", server.url());
2676
2677 let rt = Runtime::new().unwrap();
2678 let result = rt.block_on(download_file(
2679 &test_http(),
2680 &file_url,
2681 "flaky.zip",
2682 cache_dir,
2683 None,
2684 &fast_retry(2),
2685 &NoopProgress,
2686 ));
2687
2688 assert!(result.is_err());
2689
2690 mock.assert();
2692 }
2693
2694 #[test]
2695 fn test_download_file_does_not_retry_4xx() {
2696 let mut server = Server::new();
2697 let temp_dir = tempdir().unwrap();
2698 let cache_dir = temp_dir.path();
2699
2700 let mock = server
2702 .mock("GET", "/missing.zip")
2703 .with_status(404)
2704 .expect(1)
2705 .create();
2706 let file_url = format!("{}/missing.zip", server.url());
2707
2708 let rt = Runtime::new().unwrap();
2709 let result = rt.block_on(download_file(
2710 &test_http(),
2711 &file_url,
2712 "missing.zip",
2713 cache_dir,
2714 None,
2715 &fast_retry(5),
2716 &NoopProgress,
2717 ));
2718
2719 assert!(result.is_err());
2720
2721 mock.assert();
2722 }
2723
2724 struct RecordingProgress {
2727 positions: Arc<std::sync::Mutex<Vec<u64>>>,
2728 }
2729
2730 impl RecordingProgress {
2731 fn new() -> Self {
2732 Self {
2733 positions: Arc::new(std::sync::Mutex::new(Vec::new())),
2734 }
2735 }
2736 }
2737
2738 impl ProgressReporter for RecordingProgress {
2739 fn begin(&self, _label: &str, _total: Option<u64>) -> Box<dyn ProgressHandle> {
2740 Box::new(RecordingHandle {
2741 positions: self.positions.clone(),
2742 })
2743 }
2744 }
2745
2746 struct RecordingHandle {
2747 positions: Arc<std::sync::Mutex<Vec<u64>>>,
2748 }
2749
2750 impl ProgressHandle for RecordingHandle {
2751 fn inc(&self, _delta: u64) {}
2752
2753 fn set_position(&self, position: u64) {
2754 self.positions.lock().unwrap().push(position);
2755 }
2756
2757 fn set_message(&self, _msg: &str) {}
2758 fn finish(&self, _msg: Option<&str>) {}
2759 }
2760
2761 #[test]
2762 fn test_download_file_resets_progress_each_attempt() {
2763 let mut server = Server::new();
2764 let temp_dir = tempdir().unwrap();
2765 let cache_dir = temp_dir.path();
2766
2767 let mock = server
2770 .mock("GET", "/flaky.zip")
2771 .with_status(500)
2772 .expect(3)
2773 .create();
2774 let file_url = format!("{}/flaky.zip", server.url());
2775
2776 let progress = RecordingProgress::new();
2777
2778 let rt = Runtime::new().unwrap();
2779 let result = rt.block_on(download_file(
2780 &test_http(),
2781 &file_url,
2782 "flaky.zip",
2783 cache_dir,
2784 None,
2785 &fast_retry(2),
2786 &progress,
2787 ));
2788
2789 assert!(result.is_err());
2790
2791 let positions = progress.positions.lock().unwrap();
2792
2793 assert_eq!(positions.as_slice(), &[0, 0, 0]);
2795
2796 mock.assert();
2797 }
2798
2799 #[test]
2800 fn test_get_manifest_from_network_and_cache_tolerates_marker_write_failure() {
2801 let mut server = Server::new();
2802 let test_date = "Wed, 21 Feb 2024 15:30:45 GMT";
2803
2804 let mock = server
2805 .mock("GET", "/pkg/")
2806 .with_status(200)
2807 .with_header("Content-Type", "application/json")
2808 .with_header("Last-Modified", test_date)
2809 .with_body(sample_manifest_json())
2810 .create();
2811
2812 let api_url = format!("{}/pkg/", server.url());
2813 let temp_dir = tempdir().unwrap();
2814 let cache_dir = temp_dir.path();
2815
2816 std::fs::create_dir(last_modified_path(cache_dir)).unwrap();
2820
2821 let rt = Runtime::new().unwrap();
2822 let result = rt.block_on(get_manifest_from_network_and_cache(
2823 cache_dir,
2824 &test_http(),
2825 &api_url,
2826 None,
2827 &RetryPolicy::none(),
2828 &NoopProgress,
2829 ));
2830
2831 assert!(result.is_ok());
2832
2833 let manifest = result.unwrap();
2834
2835 assert_eq!(manifest.len(), 1);
2836 assert!(api_manifest_path(cache_dir).exists());
2837
2838 assert!(last_modified_path(cache_dir).is_dir());
2841
2842 mock.assert();
2843 }
2844
2845 fn sample_package(name: &str, full_name: &str, owner: &str) -> Package {
2846 Package {
2847 name: Some(name.to_string()),
2848 full_name: Some(full_name.to_string()),
2849 owner: Some(owner.to_string()),
2850 package_url: Some(format!("https://example.com/{}", name)),
2851 date_created: OffsetDateTime::now_utc(),
2852 date_updated: OffsetDateTime::now_utc(),
2853 uuid4: Some("test-uuid".to_string()),
2854 rating_score: Some(5),
2855 is_pinned: Some(false),
2856 is_deprecated: Some(false),
2857 has_nsfw_content: Some(false),
2858 categories: vec!["test".to_string()],
2859 versions: vec![],
2860 }
2861 }
2862
2863 fn sample_manifest_json() -> &'static str {
2864 r#"[
2865 {
2866 "name": "ModA",
2867 "full_name": "Owner-ModA",
2868 "owner": "Owner",
2869 "package_url": "https://example.com/mods/ModA",
2870 "date_created": "2024-01-01T12:00:00Z",
2871 "date_updated": "2024-01-02T12:00:00Z",
2872 "uuid4": "test-uuid",
2873 "rating_score": 5,
2874 "is_pinned": false,
2875 "is_deprecated": false,
2876 "has_nsfw_content": false,
2877 "categories": ["category1"],
2878 "versions": [
2879 {
2880 "name": "ModA",
2881 "full_name": "Owner-ModA",
2882 "description": "Test description",
2883 "icon": "icon.png",
2884 "version_number": "1.0.0",
2885 "dependencies": [],
2886 "download_url": "https://example.com/mods/ModA/download",
2887 "downloads": 100,
2888 "date_created": "2024-01-01T12:00:00Z",
2889 "website_url": "https://example.com",
2890 "is_active": true,
2891 "uuid4": "test-version-uuid",
2892 "file_size": 1024
2893 }
2894 ]
2895 }
2896 ]"#
2897 }
2898
2899 #[test]
2900 fn create_profile_code_posts_payload_and_returns_key() {
2901 let mut server = Server::new();
2902
2903 let mock = server
2904 .mock("POST", "/api/experimental/legacyprofile/create/")
2905 .match_header("content-type", "application/octet-stream")
2906 .match_body(mockito::Matcher::Exact("#r2modman\nQUJD".to_string()))
2907 .with_status(200)
2908 .with_header("Content-Type", "application/json")
2909 .with_body(r#"{"key":"abc-123"}"#)
2910 .create();
2911
2912 let cache = tempdir().unwrap();
2913 let client = ThunderstoreClient::builder()
2914 .base_url(server.url())
2915 .community("valheim")
2916 .cache_dir(cache.path())
2917 .build()
2918 .unwrap();
2919
2920 let rt = Runtime::new().unwrap();
2921 let key = rt
2922 .block_on(client.create_profile_code(b"#r2modman\nQUJD"))
2923 .unwrap();
2924
2925 assert_eq!(key, "abc-123");
2926 mock.assert();
2927 }
2928
2929 #[test]
2930 fn fetch_profile_code_returns_body_bytes() {
2931 let mut server = Server::new();
2932
2933 let mock = server
2934 .mock("GET", "/api/experimental/legacyprofile/get/abc-123/")
2935 .with_status(200)
2936 .with_body("#r2modman\nQUJD")
2937 .create();
2938
2939 let cache = tempdir().unwrap();
2940 let client = ThunderstoreClient::builder()
2941 .base_url(server.url())
2942 .community("valheim")
2943 .cache_dir(cache.path())
2944 .build()
2945 .unwrap();
2946
2947 let rt = Runtime::new().unwrap();
2948 let body = rt.block_on(client.fetch_profile_code("abc-123")).unwrap();
2949
2950 assert_eq!(body, b"#r2modman\nQUJD");
2951 mock.assert();
2952 }
2953
2954 #[test]
2955 fn fetch_profile_code_rejects_unsafe_code() {
2956 let cache = tempdir().unwrap();
2957 let client = ThunderstoreClient::builder()
2958 .community("valheim")
2959 .cache_dir(cache.path())
2960 .build()
2961 .unwrap();
2962
2963 let rt = Runtime::new().unwrap();
2964
2965 assert!(matches!(
2967 rt.block_on(client.fetch_profile_code("../../evil")),
2968 Err(Error::Config(_))
2969 ));
2970 }
2971}