Skip to main content

thunderstore_engine/
client.rs

1//! Async Thunderstore API client and package-index cache.
2//!
3//! [`ThunderstoreClient`](crate::client::ThunderstoreClient) fetches a community's package index
4//! (`/c/<community>/api/v1/package/`) and caches it on disk compressed with
5//! `zstd` + `bincode`, keyed by a `Last-Modified` freshness check. It also
6//! downloads package archives with bounded concurrency. All progress is
7//! reported through [`crate::progress`] so the crate stays UI-agnostic.
8//!
9//! API reference: <https://thunderstore.io/api/docs/>
10
11#![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
32/// Default Thunderstore host used when no `base_url` is supplied.
33const DEFAULT_BASE_URL: &str = "https://thunderstore.io";
34const LAST_MODIFIED_FILENAME: &str = "last_modified";
35const API_MANIFEST_FILENAME: &str = "api_manifest.bin.zst";
36
37/// Subdirectory under `cache_dir` that holds the per-index (per-community) index
38/// cache. Each community's manifest lives in its own subfolder keyed by a CRC32 of
39/// the resolved index URL, so honest communities sharing one `cache_dir` avoid
40/// accidentally reading each other's index. CRC32 is a non-cryptographic checksum,
41/// so this is not a security boundary: it does not defend against attacker-chosen
42/// `base_url`/`community` values crafted to collide.
43const INDEX_CACHE_SUBDIR: &str = "index";
44
45/// Default per-download size ceiling. Generous for real mod and texture packs
46/// while still rejecting a runaway body that would otherwise fill the disk within
47/// the request timeout. Tune it with
48/// [`ThunderstoreClientBuilder::download_max_bytes`].
49const DEFAULT_MAX_DOWNLOAD_BYTES: u64 = 2 * 1024 * 1024 * 1024;
50
51/// Default ceiling on the package-index response body (raw JSON) read into memory.
52/// A large community index can run to hundreds of megabytes, so this default is
53/// deliberately generous: it exists only to reject an absurd or runaway body from a
54/// compromised or misbehaving index host, not to bound normal use. Raise it with
55/// [`ThunderstoreClientBuilder::manifest_max_bytes`], or pass [`u64::MAX`] to
56/// disable the cap.
57const DEFAULT_MAX_MANIFEST_BYTES: u64 = 512 * 1024 * 1024;
58
59/// Fallback ceiling on the *decompressed* size of a cached index read back from
60/// disk, used only when neither the read cap nor the download cap is customised.
61///
62/// The interned-`bincode` cache is smaller than the JSON it was built from, so a
63/// manifest that passes the download cap always fits under an equal read cap. To
64/// make that guarantee automatic — so a manifest that downloads successfully can
65/// never become permanently unreadable from cache — the read cap *defaults to
66/// whatever the download cap is set to* (see [`ThunderstoreClientBuilder::build`]);
67/// this const is only the last-resort fallback. Override it with
68/// [`ThunderstoreClientBuilder::manifest_max_decompressed_bytes`].
69///
70/// Security: this caps how many bytes are inflated from a `zstd`/`bincode` cache
71/// (e.g. one planted in a shared cache dir) before decoding begins. Decoding is
72/// separately bounded by [`MAX_DECODE_ALLOC`], so a crafted high-ratio file cannot
73/// exhaust memory even under a generous cap.
74const DEFAULT_MAX_MANIFEST_DECOMPRESSED_BYTES: usize = 512 * 1024 * 1024;
75
76/// Compile-time ceiling on the total allocation `bincode`'s native decoder will
77/// commit while decoding a cached index. Because the cache uses native `Decode`,
78/// every container claims `len * size_of` against this budget up front, so a
79/// crafted high-ratio cache cannot amplify a small file into an unbounded
80/// allocation — decode errors out instead. This is a fixed structural bound that,
81/// unlike the (configurable) decompressed-size cap, never scales up with the
82/// download cap, and it sits far above any real community's cache so it never
83/// rejects a legitimate one.
84const MAX_DECODE_ALLOC: usize = 1024 * 1024 * 1024;
85
86/// Ceiling on the `last_modified` freshness marker read from disk. The marker is
87/// a short RFC 2822 date; this bounds allocation against an oversized file
88/// planted in a shared cache dir.
89const MAX_LAST_MODIFIED_BYTES: u64 = 4096;
90
91/// Thunderstore's profile-sharing endpoints. The path is `legacyprofile` upstream
92/// (that is the current endpoint's name, not a deprecation marker).
93const PROFILE_API_PATH: &str = "api/experimental/legacyprofile";
94
95/// Ceiling on a profile-code response body read into memory. A shared profile is
96/// capped at 20 MB of zip upstream, which is ~27 MB base64; this bounds a
97/// misbehaving or hostile host well above that.
98const MAX_PROFILE_CODE_BYTES: u64 = 64 * 1024 * 1024;
99
100/// An async client for a single Thunderstore community.
101///
102/// Build one with [`ThunderstoreClient::builder`].
103pub 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  /// Returns a builder for configuring a [`ThunderstoreClient`].
119  ///
120  /// `cache_dir` is the only required setting. Nothing is read, written, or sent
121  /// over the network until a method such as
122  /// [`get_manifest`](Self::get_manifest) is awaited, so building is cheap and
123  /// infallible apart from configuration validation.
124  ///
125  /// # Examples
126  ///
127  /// ```
128  /// use thunderstore_engine::client::ThunderstoreClient;
129  ///
130  /// let client = ThunderstoreClient::builder()
131  ///   .base_url("https://thunderstore.io")
132  ///   .community("valheim")
133  ///   .cache_dir("/var/cache/my-mod-manager")
134  ///   .build()
135  ///   .expect("base_url + community compose a valid index URL");
136  ///
137  /// assert_eq!(client.base_url(), "https://thunderstore.io");
138  /// assert_eq!(
139  ///   client.package_index_url(),
140  ///   "https://thunderstore.io/c/valheim/api/v1/package/"
141  /// );
142  /// ```
143  pub fn builder() -> ThunderstoreClientBuilder {
144    ThunderstoreClientBuilder::default()
145  }
146
147  /// The composed package-index URL this client will query.
148  pub fn package_index_url(&self) -> &str {
149    &self.package_index_url
150  }
151
152  /// The Thunderstore host this client targets, with any trailing slash trimmed.
153  pub fn base_url(&self) -> &str {
154    &self.base_url
155  }
156
157  /// The cache directory this client reads from and writes to.
158  pub fn cache_dir(&self) -> &Path {
159    &self.cache_dir
160  }
161
162  /// The per-community subdirectory of `cache_dir` that holds this client's
163  /// cached package index, namespaced by a CRC32 of the resolved index URL so two
164  /// honest communities sharing one `cache_dir` avoid accidental collisions. The
165  /// CRC32 key guards against accidental clashes only, not attacker-chosen
166  /// `base_url`/`community` values crafted to collide, so it is not a security
167  /// boundary.
168  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  /// Retrieves the package index, loading from cache when it is fresh.
176  ///
177  /// Performs a `HEAD` request and compares the server's `Last-Modified` against
178  /// the cached timestamp; if the cache is current it is loaded from disk,
179  /// otherwise the index is downloaded and cached.
180  ///
181  /// # Errors
182  ///
183  /// A failed freshness check is **not** an error on its own: it is logged and a
184  /// readable cache is served instead, so this succeeds offline once the index
185  /// has been fetched at least once. Errors therefore mean no usable cache *and*
186  /// a failed fetch:
187  ///
188  /// - [`Error::Http`] if the download fails or returns a non-success status.
189  /// - [`Error::SizeLimitExceeded`] if the response exceeds
190  ///   [`manifest_max_bytes`](ThunderstoreClientBuilder::manifest_max_bytes), or
191  ///   the cached copy exceeds
192  ///   [`manifest_max_decompressed_bytes`](ThunderstoreClientBuilder::manifest_max_decompressed_bytes).
193  /// - [`Error::Json`] if the index is not valid Thunderstore package JSON.
194  /// - [`Error::Io`] if the cache directory cannot be created or written.
195  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  /// Forces a network refresh of the package index, bypassing the cache.
238  ///
239  /// # Errors
240  ///
241  /// The same set as [`get_manifest`](Self::get_manifest), minus the cache
242  /// fallback: because this always goes to the network, any network failure is
243  /// fatal here even when a perfectly good cached index exists.
244  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  /// Downloads multiple package archives concurrently into `cache_dir/downloads`.
257  ///
258  /// Keys of `urls` are filenames and values are download URLs. Files that
259  /// already exist are skipped. Concurrency is bounded by the configured
260  /// `download_concurrency`, and each archive is bounded by the configured
261  /// `download_max_bytes`. Each archive is written atomically (temp file +
262  /// rename) after its full `Content-Length` is received. Individual download
263  /// failures are logged and, if any occurred, surfaced as an aggregate error
264  /// rather than silently ignored.
265  ///
266  /// # Errors
267  ///
268  /// Returns a single [`Error::Download`] carrying how many of how many files
269  /// failed plus the first failure's message. **This is all-or-nothing only in
270  /// its reporting, not its effects**: files that succeeded are still on disk, so
271  /// a caller that retries should expect those to be skipped as already present.
272  /// The underlying per-file causes are [`Error::Http`],
273  /// [`Error::SizeLimitExceeded`] (archive over
274  /// [`download_max_bytes`](ThunderstoreClientBuilder::download_max_bytes)),
275  /// [`Error::IncompleteDownload`] (body shorter than its `Content-Length`), and
276  /// [`Error::Io`].
277  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  /// Uploads a profile-code payload and returns the server-issued key.
291  ///
292  /// `payload` is sent verbatim as `application/octet-stream`; composing it (the
293  /// `#r2modman` prefix and base64 body) belongs to
294  /// [`crate::profile::portability`]. Transient failures are retried per the
295  /// client's [`RetryPolicy`].
296  ///
297  /// # Errors
298  ///
299  /// - [`Error::Http`] if the upload fails or the server returns a non-success
300  ///   status, after the retry policy is exhausted.
301  /// - [`Error::SizeLimitExceeded`] if the response body is implausibly large for
302  ///   a profile key.
303  /// - [`Error::Json`] if the response is not the expected `{ "key": ... }` shape.
304  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  /// Downloads a shared profile by its code, returning the raw response body.
328  ///
329  /// Validating the `#r2modman` prefix and decoding the base64 body belongs to
330  /// [`crate::profile::portability`].
331  ///
332  /// # Errors
333  ///
334  /// - [`Error::Config`] if `code` is empty or contains anything outside ASCII
335  ///   alphanumerics, `-`, and `_`. This is checked before any request, so a
336  ///   malformed code never reaches the network and can never be smuggled into
337  ///   the request path.
338  /// - [`Error::Http`] if the download fails or the code is unknown to the
339  ///   server, after the retry policy is exhausted.
340  /// - [`Error::SizeLimitExceeded`] if the shared profile exceeds the response
341  ///   cap.
342  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
358/// Builder for [`ThunderstoreClient`].
359pub 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      // `None` means "track the download cap" (resolved in `build`), so a manifest
386      // that passes the download cap is always readable back from cache.
387      max_manifest_decompressed_bytes: None,
388      retry: RetryPolicy::default(),
389    }
390  }
391}
392
393impl ThunderstoreClientBuilder {
394  /// Sets the Thunderstore host (default `https://thunderstore.io`).
395  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  /// Sets the community slug — the lowercase, hyphenated identifier in a
402  /// community's Thunderstore URL.
403  pub fn community(mut self, community: impl Into<String>) -> Self {
404    self.community = Some(community.into());
405
406    self
407  }
408
409  /// Overrides the fully composed package-index URL (mainly for tests/mirrors).
410  ///
411  /// When set, this takes precedence over `base_url`/`community` composition.
412  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  /// Sets the cache directory used for the index and downloads.
419  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  /// Sets the progress reporter (default: [`NoopProgress`]).
426  pub fn progress(mut self, progress: Arc<dyn ProgressReporter>) -> Self {
427    self.progress = Some(progress);
428
429    self
430  }
431
432  /// Sets the maximum number of concurrent downloads (default 2).
433  pub fn download_concurrency(mut self, concurrency: usize) -> Self {
434    self.download_concurrency = concurrency;
435
436    self
437  }
438
439  /// Sets the network timeout (default 60s) applied as both the connection
440  /// timeout and the per-read (idle) timeout — *not* a deadline on the whole
441  /// request. A large archive can therefore take arbitrarily long to download as
442  /// long as data keeps flowing, while a stalled connection still fails promptly.
443  pub fn download_timeout(mut self, timeout: Duration) -> Self {
444    self.download_timeout = timeout;
445
446    self
447  }
448
449  /// Sets the maximum size, in bytes, of the package-index response body (raw
450  /// JSON) read into memory. The default is a generous ceiling that accommodates
451  /// large community indices; a response whose declared `Content-Length` or actual
452  /// streamed size exceeds it is rejected, bounding memory against an oversized
453  /// index from a compromised or misbehaving host. Pass [`u64::MAX`] to
454  /// effectively disable the cap.
455  ///
456  /// Unless [`manifest_max_decompressed_bytes`](Self::manifest_max_decompressed_bytes)
457  /// is also set, the cache read-back cap tracks this value, so raising this alone
458  /// keeps a downloaded manifest readable back from cache.
459  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  /// Sets the maximum *decompressed* size, in bytes, of the on-disk index cache
466  /// permitted when reading it back.
467  ///
468  /// By default (when this is left unset) the read cap tracks
469  /// [`manifest_max_bytes`](Self::manifest_max_bytes), i.e. the download cap. The
470  /// interned-`bincode` cache is always smaller than the JSON it was built from, so
471  /// tracking the download cap guarantees any manifest that downloads successfully
472  /// can be read back from cache rather than being silently re-downloaded on every
473  /// run.
474  ///
475  /// Set this explicitly to bound decode allocation more tightly than the download
476  /// cap — e.g. when a `cache_dir` is shared across mutually-distrusting users and
477  /// you want a smaller ceiling. Setting it *below* what your community's cache
478  /// actually needs will make that cache unreadable (it re-downloads instead), so
479  /// size it to your community. Pass [`u64::MAX`] to effectively disable the cap.
480  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  /// Sets the retry policy for transient network failures (default: a few
487  /// retries with exponential backoff). Retries apply to package downloads and
488  /// the index download, only for transient errors (connection/timeout/body,
489  /// `5xx`, `429`) — never a `4xx` or a terminal error. Pass
490  /// [`RetryPolicy::none`] to disable retrying.
491  pub fn retry(mut self, policy: RetryPolicy) -> Self {
492    self.retry = policy;
493
494    self
495  }
496
497  /// Convenience for [`retry`](Self::retry): sets only the maximum number of
498  /// retries, keeping the default backoff.
499  pub fn max_retries(mut self, max_retries: u32) -> Self {
500    self.retry.max_retries = max_retries;
501
502    self
503  }
504
505  /// Sets the maximum size, in bytes, of a single package archive download. The
506  /// default is a generous ceiling; a download whose declared `Content-Length` or
507  /// actual streamed size exceeds it is rejected with
508  /// [`Error::SizeLimitExceeded`], which
509  /// bounds disk use against an oversized or runaway body. Pass [`u64::MAX`] to
510  /// effectively disable the cap.
511  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  /// Builds the [`ThunderstoreClient`].
518  ///
519  /// # Errors
520  ///
521  /// Returns an error if `cache_dir` was not set, the `community` slug is
522  /// invalid, or the HTTP client cannot be constructed.
523  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    // The timeout is applied as a connection + per-read (idle) timeout rather
549    // than a deadline on the whole request, so a large but healthy download is
550    // not cut off mid-stream while a stalled socket still fails promptly.
551    // Redirects are followed but bounded (reqwest's default limit) and never for
552    // non-HTTP schemes; download URLs come from the fetched index, so this is a
553    // deliberate, documented policy.
554    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
577/// Validates a community slug so it cannot inject extra path segments, a query,
578/// or `..` into the composed index URL. Real Thunderstore communities are
579/// lowercase alphanumeric slugs with hyphens.
580fn 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
596/// Composes the package-index URL from the builder's configuration.
597///
598/// An explicit `override_url` (e.g. a mirror or a test server) takes precedence
599/// and is used verbatim. Otherwise the URL is built from `base_url` and the
600/// community slug, which is validated first so it cannot inject extra path
601/// segments. Returns an error if neither an override nor a community is given,
602/// or the community slug is invalid.
603fn 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/// The `POST …/legacyprofile/create/` response body.
624#[derive(serde::Deserialize)]
625struct ProfileCodeResponse {
626  key: String,
627}
628
629/// Validates a profile code before it is interpolated into a URL path, so a
630/// crafted code cannot inject extra path segments or a query. Real codes are
631/// server-issued identifiers of ASCII alphanumerics and hyphens.
632fn 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
647/// Resolves the maximum *decompressed* cache size from the two configured caps.
648///
649/// An explicit `configured` value wins. Otherwise the read-back cap tracks the
650/// `download_cap` so any manifest that passes the (JSON) download cap fits under
651/// it once interned — the bincode cache is always smaller than the JSON — and can
652/// never become unreadable from cache. With neither set, the fallback
653/// [`DEFAULT_MAX_MANIFEST_DECOMPRESSED_BYTES`] applies.
654fn 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
661/// Returns the path to the last_modified file in the cache directory.
662fn last_modified_path(cache_dir: &Path) -> PathBuf {
663  cache_dir.join(LAST_MODIFIED_FILENAME)
664}
665
666/// Returns the path to the cached API manifest file in the cache directory.
667fn api_manifest_path(cache_dir: &Path) -> PathBuf {
668  cache_dir.join(API_MANIFEST_FILENAME)
669}
670
671/// Reads the cached API manifest from disk using the fallback decompressed-size
672/// cap ([`DEFAULT_MAX_MANIFEST_DECOMPRESSED_BYTES`]).
673///
674/// The cache is a `zstd` + `bincode`-encoded interned manifest. A corrupt or
675/// truncated file is reported as an error rather than panicking, and the
676/// decompressed size is hard-capped. That cap — not any per-container decode limit
677/// — is what bounds allocation, so a crafted cache planted in a shared cache dir
678/// can still expand into a bounded amount of `Vec` backing stores but no more. The
679/// live client passes its configured cap via
680/// [`get_manifest_from_disk_with_limit`]; this convenience wrapper (used by tests)
681/// applies the fallback cap.
682#[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
687/// [`get_manifest_from_disk`] with an explicit decompressed-size cap.
688async 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  // The zstd inflate + bincode decode are blocking CPU/IO work, so they run on a
699  // blocking thread rather than stalling the async runtime.
700  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
705/// Whether a cache dated `local` is still fresh given the server's `network`
706/// last-modified time — i.e. the server has nothing newer than what is cached.
707fn is_fresh(network: DateTime<FixedOffset>, local: DateTime<FixedOffset>) -> bool {
708  network <= local
709}
710
711/// Loads the cached index, logging a warning and returning `None` — so the
712/// caller re-fetches from the network — when it is missing or unreadable.
713///
714/// This is the shared fallback used on both the fresh-cache and
715/// freshness-check-failed paths of [`ThunderstoreClient::get_manifest`].
716async 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
727/// Reads and decodes a cached index from `path` on the current (blocking) thread.
728///
729/// The decode streams straight from the file with a hard cap on the decompressed
730/// output, so the inflated bytes are never larger than `max_decompressed` before
731/// [`PackageIndex::validate`] (which runs only after a full decode) sees them. The
732/// `bincode` serde deserializer can still expand those bytes into larger `Vec`
733/// backing stores, so it is this decompressed-size cap that ultimately bounds
734/// allocation for a high-ratio file planted in a shared cache dir.
735fn 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  // Native `bincode::Decode` (not the serde bridge): its `with_limit` makes the
756  // decoder claim `len * size_of` up front for every container, so a crafted cache
757  // cannot amplify a small input into a huge allocation — decode fails once the
758  // running total would exceed [`MAX_DECODE_ALLOC`]. That structural ceiling holds
759  // regardless of the (configurable, larger) decompressed-size cap above.
760  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
768/// Downloads the manifest from the API server.
769///
770/// The response body is bounded by `max_bytes` (checked against the declared
771/// `Content-Length` up front and the actual streamed size) so an oversized index
772/// from a compromised or misbehaving host cannot exhaust memory.
773async 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  // Retry only the transient network parts; a missing header, oversize body, or
783  // JSON parse error is terminal and returns on the first attempt.
784  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  // Finish the handle on both paths so a real reporter never leaves an orphaned
803  // bar when the download ultimately fails.
804  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
818/// Reads a response body fully into memory, rejecting one that exceeds
819/// `max_bytes` — either by its declared `Content-Length` (before streaming) or by
820/// its actual streamed size (for chunked bodies with no declared length).
821async 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
844/// Interns the packages into a [`PackageIndex`] and encodes the on-disk cache
845/// blob (the serializable view, `bincode`-encoded) in one CPU-bound pass.
846///
847/// Pure: no I/O. Runs inside `spawn_blocking` on the caching path, and is
848/// directly unit-testable without a server or temp directory.
849fn 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
859/// Downloads the manifest from the network and caches it locally.
860///
861/// The manifest is written to disk *before* the `last_modified` freshness marker
862/// is advanced, so an interrupted or failed write can never leave the marker
863/// pointing at a timestamp the on-disk manifest doesn't actually have (which
864/// would pin a stale cache as "fresh").
865async 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  // Interning every package string, building the serializable view, and the
877  // bincode encode are all CPU-bound, so they run on a blocking thread rather than
878  // stalling the async runtime — mirroring the decode-path offload above.
879  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  // Only now that the manifest content is durably on disk do we advance the
887  // freshness marker. The marker write is best-effort: the manifest is already
888  // persisted, so a failure here just leaves a missing/stale marker that triggers
889  // a harmless re-fetch on the next call rather than discarding valid cached data.
890  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
906/// Retrieves the last modified date from the local cache file.
907async 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    // Cap the read so an oversized file planted in a shared cache dir cannot
914    // exhaust memory, and decode lossily: a missing, non-UTF-8, or unparseable
915    // marker is treated as "unknown" (epoch), so a corrupt/legacy marker triggers
916    // a re-fetch rather than failing the whole call — the marker is only a hint.
917    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
944/// The Unix epoch as a fixed-offset timestamp, used as the "unknown / very old"
945/// sentinel for cache freshness so any real server timestamp is newer.
946fn 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
955/// Retrieves the last modified date from the API server via a `HEAD` request.
956async 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
971/// Checks for the existence of any version of the cached manifest file.
972fn api_manifest_file_exists(cache_dir: &Path) -> bool {
973  api_manifest_path(cache_dir).exists()
974}
975
976/// Computes a unique sibling temp path next to `path` for atomic writes.
977///
978/// The temp file lives in the same directory as the destination so the
979/// subsequent rename is atomic on the same filesystem. A per-process counter and
980/// the process id keep concurrent writers from colliding on the same temp name.
981fn 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
995/// Writes data to a cache file on disk, optionally compressing with zstd level 9.
996///
997/// The write is atomic: data is written to a sibling temp file, `fsync`ed, then
998/// renamed over the destination, so an interrupted write can never leave a
999/// truncated or partially-written cache file in place.
1000async 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    // zstd level 9 is CPU-bound; run it off the async runtime thread.
1013    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  // On any failure, remove the sibling temp file so a failed write does not leave
1025  // an orphan behind in the cache dir.
1026  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
1035/// Writes `payload` to `tmp_path`, fsyncs it, renames it over `path`, then fsyncs
1036/// the containing directory so the rename (the new directory entry) is durable
1037/// across a crash — not just the file's contents.
1038async 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  // Best-effort directory fsync: succeeds on Unix, may be unsupported elsewhere,
1048  // so a failure here is not treated as fatal.
1049  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
1058/// Parses a `Content-Length` header into a byte count, returning `None` when the
1059/// header is absent or unparseable (e.g. a chunked body, or a malformed value).
1060fn 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
1067/// Verifies a finished download received exactly the declared number of bytes.
1068///
1069/// When the response declared a `Content-Length`, a stream that ends with a
1070/// different number of bytes is an incomplete/corrupt transfer and is rejected
1071/// with [`Error::IncompleteDownload`]. With no declared length (a chunked body),
1072/// any clean end is accepted. Pure — no I/O.
1073fn 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
1086/// Collapses the per-file download results into a single outcome: `Ok(())` if
1087/// every file succeeded, otherwise an [`Error::Download`] reporting how many of
1088/// `total` failed and the first failure's message. Pure over the result vector.
1089fn 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
1116/// Downloads multiple files concurrently with progress indicators.
1117async 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
1163/// Validates a download filename, rejecting absolute paths and any path
1164/// traversal (`..`, separators) so a download cannot escape the downloads
1165/// directory.
1166fn 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
1182/// Best-effort cleanup guard for a download's sibling temp file.
1183///
1184/// While armed, dropping the guard removes the temp file, so a download future
1185/// that is cancelled (dropped) mid-stream — or that returns early on error — never
1186/// leaves an orphan `.tmp` behind. Call [`disarm`](TempFileGuard::disarm) once the
1187/// file has been renamed into place, at which point there is nothing left to
1188/// remove at the temp path.
1189struct TempFileGuard {
1190  path: Option<PathBuf>,
1191}
1192
1193impl TempFileGuard {
1194  fn new(path: PathBuf) -> Self {
1195    Self { path: Some(path) }
1196  }
1197
1198  /// Stops the guard from removing the temp file, e.g. after a successful rename.
1199  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
1212/// Downloads a single file from a URL with a progress indicator.
1213///
1214/// The body is streamed to a sibling temp file and renamed into place only after
1215/// the full `Content-Length` has been received, so an interrupted download never
1216/// leaves a truncated file that would be mistaken for a complete one on retry.
1217///
1218/// Transient failures (connection/timeout/body errors, `5xx`, `429`) are retried
1219/// per `retry`; each attempt re-creates (truncates) the temp file, so a retry
1220/// restarts cleanly. A `4xx` and the terminal size-cap/incomplete errors are not
1221/// retried.
1222///
1223/// When `max_bytes` is set, the download is rejected — before streaming if the
1224/// server declares an oversized `Content-Length`, and mid-stream once the actual
1225/// bytes written exceed the cap — so an oversized or runaway body cannot fill the
1226/// disk. A failed *or cancelled* download never leaves a partial file behind: a
1227/// drop-guard removes the temp file on any early return or if the future is
1228/// dropped mid-stream, and is disarmed only once the file is renamed into place.
1229async 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  // Removes the temp file if this future is dropped (cancelled) mid-download or
1261  // returns early on error; disarmed only once the file is renamed into place.
1262  let mut tmp_guard = TempFileGuard::new(tmp_path.clone());
1263
1264  // One progress handle spans all attempts and is finished exactly once (below),
1265  // so a retried transient failure never orphans a half-finished bar. Its total
1266  // is set once the response's Content-Length is known.
1267  let handle = progress.begin(filename, None);
1268
1269  // Each attempt re-runs the request and re-creates the temp file, so a retried
1270  // transient failure restarts from a clean slate. Terminal errors (a declared or
1271  // streamed oversize, an incomplete transfer) short-circuit the retry loop.
1272  let attempt = retry_http(retry, || async {
1273    // Rewind the bar to zero at the start of every attempt so a retried mid-stream
1274    // failure re-inc'ing re-downloaded bytes cannot overshoot the total.
1275    handle.set_position(0);
1276
1277    let response = client.get(url).send().await?.error_for_status()?;
1278
1279    // `Content-Length` is optional: servers using chunked transfer encoding omit
1280    // it. When present it gives the progress total and a completeness check; when
1281    // absent (or malformed) a stream that finishes without a transport error is
1282    // treated as complete.
1283    let content_length = parse_content_length(response.headers());
1284    let mut cap = max_bytes.map(|max| SizeCap::new("download body", max));
1285
1286    // Reject a declared-oversize body up front, before streaming a single byte.
1287    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    // `tmp_guard` removes the temp file as it drops on return.
1326    handle.finish(None);
1327
1328    return Err(err);
1329  }
1330
1331  // Rename into place first; only report completion once the file actually
1332  // lands, so a rename failure is never shown to the UI as a finished download.
1333  // On a rename failure `tmp_guard` removes the leftover temp file on return.
1334  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  // The temp file has been renamed away, so there is nothing left to clean up.
1344  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    // A trailing slash on the base is trimmed so the path is never doubled.
1473    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    // An override is used verbatim, ignoring base/community.
1479    assert_eq!(
1480      compose_index_url("https://ignored", None, Some("https://custom/pkg/")).unwrap(),
1481      "https://custom/pkg/"
1482    );
1483
1484    // Neither override nor community => Config error.
1485    assert!(matches!(
1486      compose_index_url("https://thunderstore.io", None, None),
1487      Err(Error::Config(_))
1488    ));
1489
1490    // An invalid community slug is rejected.
1491    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    // An explicit configured value wins.
1500    assert_eq!(resolve_decompressed_cap(Some(42), Some(100)), 42);
1501
1502    // Unset falls back to the download cap.
1503    assert_eq!(resolve_decompressed_cap(None, Some(100)), 100);
1504
1505    // Neither set falls back to the default.
1506    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    // Server has nothing newer than the cache => fresh (serve cache).
1518    assert!(is_fresh(older, older));
1519    assert!(is_fresh(older, newer));
1520
1521    // Server is newer than the cache => stale (re-download).
1522    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    // Exact match against the declared length is complete.
1536    assert!(check_complete(Some(100), 100).is_ok());
1537
1538    // No declared length (chunked) => any clean end is accepted.
1539    assert!(check_complete(None, 42).is_ok());
1540
1541    // Fewer bytes than declared => incomplete transfer.
1542    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    // A plain single-component filename is accepted verbatim.
1556    assert_eq!(
1557      safe_download_name("Author-Mod-1.0.0.zip").unwrap(),
1558      "Author-Mod-1.0.0.zip"
1559    );
1560
1561    // Parent-dir traversal, an absolute path, and an embedded separator are all
1562    // rejected so a download can never escape the downloads directory. (A
1563    // backslash is a normal filename char on Unix and is intentionally not
1564    // covered here — the guard is about real path separators.)
1565    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  /// Seeds a valid compressed cache and a `last_modified` marker into `index_dir`.
1778  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    // The server reports an old timestamp, so the recently-cached copy wins.
1805    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    // The freshness HEAD request fails (server 500). The cache must still load.
1840    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    // `refresh_index` never issues a HEAD; it downloads unconditionally.
1877    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    // A fresh cache is seeded that would otherwise be served; refresh ignores it.
1893    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    // The network index ("Owner-ModA"), not the cached one, is returned.
1900    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    // HEAD reports a timestamp newer than the seeded cache => stale => download.
1915    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    // The freshly downloaded (network) index wins over the stale cache.
1941    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    // The mock permits exactly one hit; a second download that re-fetched would
1957    // issue an unmatched request and fail `assert()`.
1958    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    // The file now exists, so the second call must short-circuit before any HTTP.
1980    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    // A 500 that still carries a (future) Last-Modified must NOT be trusted as
2001    // the freshness signal; the cached copy is served instead.
2002    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    // HEAD says the cache is fresh, but the cached file is written by an
2032    // incompatible format version. The client must fall back to a network GET
2033    // rather than error.
2034    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    // Seed a fresh marker but a cache blob with a bad format version.
2056    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    // The network copy (Owner-ModA) replaced the unreadable cache.
2081    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    // Left unset, the cache read-back cap tracks the download cap, so a manifest
2192    // that passes the download cap is always readable back from cache.
2193    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    // Raising only the download cap raises the read cap with it.
2205    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    // An explicit read cap overrides the tracking default (e.g. to bound decode
2218    // allocation more tightly than the download cap).
2219    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    // A multi-package index with distinct strings, so the interned cache is a
2236    // realistic (if far smaller) stand-in for a large community's cache rather than
2237    // a trivial one-package blob.
2238    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    // A cap at least the decompressed size reads the whole cache back.
2263    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    // The default cap comfortably accommodates a real community's cache, so the
2273    // same cache also round-trips under the default.
2274    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    // A cap below the decompressed size still rejects the cache, preserving the
2286    // decompression-bomb guard at the configured boundary.
2287    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    // No Content-Length header (as with chunked transfer encoding). The download
2406    // must still succeed, treating a cleanly finished stream as complete.
2407    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    // The server declares a Content-Length larger than the cap, so the download
2585    // is rejected up front without streaming the body.
2586    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    // A chunked body carries no Content-Length, so the cap must be enforced from
2620    // the actual streamed bytes rather than a declared size.
2621    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    // Every attempt gets a 500; with 2 retries the endpoint is hit 3 times.
2670    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    // Exactly initial + 2 retries.
2691    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    // A 404 is terminal: even with a generous retry budget it is hit once.
2701    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  /// A [`ProgressReporter`] that records every absolute position reset, used to
2725  /// assert the bar is rewound to zero at the start of each retried attempt.
2726  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    // Every attempt gets a 500; with 2 retries the closure runs 3 times, so the
2768    // bar must be reset to zero once at the start of each attempt.
2769    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    // One `set_position(0)` per attempt (initial + 2 retries).
2794    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    // Occupy the marker path with a directory so the marker write fails while the
2817    // manifest blob still persists. A marker-write failure must not discard the
2818    // already-cached manifest.
2819    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    // The marker write really did fail (the path is still the directory we
2839    // planted), confirming the tolerated branch was exercised.
2840    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    // A code is interpolated into the URL path, so it must not inject segments.
2966    assert!(matches!(
2967      rt.block_on(client.fetch_profile_code("../../evil")),
2968      Err(Error::Config(_))
2969    ));
2970  }
2971}