#![deny(missing_docs)]
use crate::error::{Error, Result};
use crate::models::{Package, PackageIndex, SerializedPackageIndex};
use crate::progress::{NoopProgress, ProgressReporter};
use crate::retry::{RetryPolicy, retry_http};
use crate::util::{SizeCap, io_context};
use chrono::prelude::*;
use futures::stream::StreamExt;
use reqwest::Client;
use reqwest::header;
use std::collections::HashMap;
use std::io::Read;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
use tokio::fs;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
const DEFAULT_BASE_URL: &str = "https://thunderstore.io";
const LAST_MODIFIED_FILENAME: &str = "last_modified";
const API_MANIFEST_FILENAME: &str = "api_manifest.bin.zst";
const INDEX_CACHE_SUBDIR: &str = "index";
const DEFAULT_MAX_DOWNLOAD_BYTES: u64 = 2 * 1024 * 1024 * 1024;
const DEFAULT_MAX_MANIFEST_BYTES: u64 = 512 * 1024 * 1024;
const DEFAULT_MAX_MANIFEST_DECOMPRESSED_BYTES: usize = 512 * 1024 * 1024;
const MAX_DECODE_ALLOC: usize = 1024 * 1024 * 1024;
const MAX_LAST_MODIFIED_BYTES: u64 = 4096;
const PROFILE_API_PATH: &str = "api/experimental/legacyprofile";
const MAX_PROFILE_CODE_BYTES: u64 = 64 * 1024 * 1024;
pub struct ThunderstoreClient {
http: Client,
package_index_url: String,
base_url: String,
cache_dir: PathBuf,
index_cache_key: String,
progress: Arc<dyn ProgressReporter>,
download_concurrency: usize,
max_download_bytes: Option<u64>,
max_manifest_bytes: Option<u64>,
max_manifest_decompressed_bytes: usize,
retry: RetryPolicy,
}
impl ThunderstoreClient {
pub fn builder() -> ThunderstoreClientBuilder {
ThunderstoreClientBuilder::default()
}
pub fn package_index_url(&self) -> &str {
&self.package_index_url
}
pub fn base_url(&self) -> &str {
&self.base_url
}
pub fn cache_dir(&self) -> &Path {
&self.cache_dir
}
fn index_cache_dir(&self) -> PathBuf {
self
.cache_dir
.join(INDEX_CACHE_SUBDIR)
.join(&self.index_cache_key)
}
pub async fn get_manifest(&self) -> Result<PackageIndex> {
let index_dir = self.index_cache_dir();
let last_modified = local_last_modified(&index_dir).await?;
tracing::info!("Manifest last modified: {}", last_modified);
if api_manifest_file_exists(&index_dir) {
match network_last_modified(&self.http, &self.package_index_url).await {
Ok(network) if is_fresh(network, last_modified) => {
tracing::info!("Loading manifest from cache");
if let Some(manifest) =
load_cached_index(&index_dir, self.max_manifest_decompressed_bytes).await
{
return Ok(manifest);
}
}
Ok(_) => tracing::info!("Cache is stale; downloading new manifest"),
Err(err) => {
tracing::warn!("Freshness check failed ({}); serving cached manifest", err);
if let Some(manifest) =
load_cached_index(&index_dir, self.max_manifest_decompressed_bytes).await
{
return Ok(manifest);
}
}
}
}
tracing::info!("Downloading new manifest");
get_manifest_from_network_and_cache(
&index_dir,
&self.http,
&self.package_index_url,
self.max_manifest_bytes,
&self.retry,
self.progress.as_ref(),
)
.await
}
pub async fn refresh_index(&self) -> Result<PackageIndex> {
get_manifest_from_network_and_cache(
&self.index_cache_dir(),
&self.http,
&self.package_index_url,
self.max_manifest_bytes,
&self.retry,
self.progress.as_ref(),
)
.await
}
pub async fn download_files(&self, urls: HashMap<String, String>) -> Result<()> {
download_files(
urls,
&self.cache_dir,
&self.http,
self.download_concurrency,
self.max_download_bytes,
self.retry,
self.progress.clone(),
)
.await
}
pub async fn create_profile_code(&self, payload: &[u8]) -> Result<String> {
let url = format!("{}/{}/create/", self.base_url, PROFILE_API_PATH);
let key = retry_http(&self.retry, || async {
let response = self
.http
.post(&url)
.header(header::CONTENT_TYPE, "application/octet-stream")
.body(payload.to_vec())
.send()
.await?
.error_for_status()?;
let body = read_capped_body(response, Some(MAX_PROFILE_CODE_BYTES)).await?;
let parsed: ProfileCodeResponse = serde_json::from_slice(&body)?;
Ok(parsed.key)
})
.await?;
Ok(key)
}
pub async fn fetch_profile_code(&self, code: &str) -> Result<Vec<u8>> {
validate_profile_code(code)?;
let url = format!("{}/{}/get/{}/", self.base_url, PROFILE_API_PATH, code);
let body = retry_http(&self.retry, || async {
let response = self.http.get(&url).send().await?.error_for_status()?;
read_capped_body(response, Some(MAX_PROFILE_CODE_BYTES)).await
})
.await?;
Ok(body)
}
}
pub struct ThunderstoreClientBuilder {
base_url: Option<String>,
community: Option<String>,
package_index_url: Option<String>,
cache_dir: Option<PathBuf>,
progress: Option<Arc<dyn ProgressReporter>>,
download_concurrency: usize,
download_timeout: Duration,
max_download_bytes: Option<u64>,
max_manifest_bytes: Option<u64>,
max_manifest_decompressed_bytes: Option<u64>,
retry: RetryPolicy,
}
impl Default for ThunderstoreClientBuilder {
fn default() -> Self {
Self {
base_url: None,
community: None,
package_index_url: None,
cache_dir: None,
progress: None,
download_concurrency: 2,
download_timeout: Duration::from_secs(60),
max_download_bytes: Some(DEFAULT_MAX_DOWNLOAD_BYTES),
max_manifest_bytes: Some(DEFAULT_MAX_MANIFEST_BYTES),
max_manifest_decompressed_bytes: None,
retry: RetryPolicy::default(),
}
}
}
impl ThunderstoreClientBuilder {
pub fn base_url(mut self, base_url: impl Into<String>) -> Self {
self.base_url = Some(base_url.into());
self
}
pub fn community(mut self, community: impl Into<String>) -> Self {
self.community = Some(community.into());
self
}
pub fn package_index_url(mut self, url: impl Into<String>) -> Self {
self.package_index_url = Some(url.into());
self
}
pub fn cache_dir(mut self, cache_dir: impl Into<PathBuf>) -> Self {
self.cache_dir = Some(cache_dir.into());
self
}
pub fn progress(mut self, progress: Arc<dyn ProgressReporter>) -> Self {
self.progress = Some(progress);
self
}
pub fn download_concurrency(mut self, concurrency: usize) -> Self {
self.download_concurrency = concurrency;
self
}
pub fn download_timeout(mut self, timeout: Duration) -> Self {
self.download_timeout = timeout;
self
}
pub fn manifest_max_bytes(mut self, max_bytes: u64) -> Self {
self.max_manifest_bytes = Some(max_bytes);
self
}
pub fn manifest_max_decompressed_bytes(mut self, max_bytes: u64) -> Self {
self.max_manifest_decompressed_bytes = Some(max_bytes);
self
}
pub fn retry(mut self, policy: RetryPolicy) -> Self {
self.retry = policy;
self
}
pub fn max_retries(mut self, max_retries: u32) -> Self {
self.retry.max_retries = max_retries;
self
}
pub fn download_max_bytes(mut self, max_bytes: u64) -> Self {
self.max_download_bytes = Some(max_bytes);
self
}
pub fn build(self) -> Result<ThunderstoreClient> {
let cache_dir = self
.cache_dir
.ok_or_else(|| Error::Config("cache_dir is required".to_string()))?;
let base_url = self
.base_url
.clone()
.unwrap_or_else(|| DEFAULT_BASE_URL.to_string())
.trim_end_matches('/')
.to_string();
let package_index_url = compose_index_url(
self.base_url.as_deref().unwrap_or(DEFAULT_BASE_URL),
self.community.as_deref(),
self.package_index_url.as_deref(),
)?;
let index_cache_key = format!("{:08x}", crc32fast::hash(package_index_url.as_bytes()));
let max_manifest_decompressed_bytes = resolve_decompressed_cap(
self.max_manifest_decompressed_bytes,
self.max_manifest_bytes,
);
let http = Client::builder()
.connect_timeout(self.download_timeout)
.read_timeout(self.download_timeout)
.redirect(reqwest::redirect::Policy::limited(10))
.build()?;
let progress = self.progress.unwrap_or_else(|| Arc::new(NoopProgress));
Ok(ThunderstoreClient {
http,
package_index_url,
base_url,
cache_dir,
index_cache_key,
progress,
download_concurrency: self.download_concurrency,
max_download_bytes: self.max_download_bytes,
max_manifest_bytes: self.max_manifest_bytes,
max_manifest_decompressed_bytes,
retry: self.retry,
})
}
}
fn validate_community(community: &str) -> Result<()> {
let is_slug = !community.is_empty()
&& community
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_');
if !is_slug {
return Err(Error::Config(format!(
"invalid community slug {:?}: expected only ASCII letters, digits, '-', and '_'",
community
)));
}
Ok(())
}
fn compose_index_url(
base_url: &str,
community: Option<&str>,
override_url: Option<&str>,
) -> Result<String> {
if let Some(url) = override_url {
return Ok(url.to_string());
}
let community = community.ok_or_else(|| Error::Config("community is required".to_string()))?;
validate_community(community)?;
Ok(format!(
"{}/c/{}/api/v1/package/",
base_url.trim_end_matches('/'),
community
))
}
#[derive(serde::Deserialize)]
struct ProfileCodeResponse {
key: String,
}
fn validate_profile_code(code: &str) -> Result<()> {
let is_valid = !code.is_empty()
&& code
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_');
if !is_valid {
return Err(Error::Config(format!(
"invalid profile code {code:?}: expected only ASCII letters, digits, '-', and '_'"
)));
}
Ok(())
}
fn resolve_decompressed_cap(configured: Option<u64>, download_cap: Option<u64>) -> usize {
configured
.or(download_cap)
.map(|n| usize::try_from(n).unwrap_or(usize::MAX))
.unwrap_or(DEFAULT_MAX_MANIFEST_DECOMPRESSED_BYTES)
}
fn last_modified_path(cache_dir: &Path) -> PathBuf {
cache_dir.join(LAST_MODIFIED_FILENAME)
}
fn api_manifest_path(cache_dir: &Path) -> PathBuf {
cache_dir.join(API_MANIFEST_FILENAME)
}
#[cfg(test)]
async fn get_manifest_from_disk(cache_dir: &Path) -> Result<PackageIndex> {
get_manifest_from_disk_with_limit(cache_dir, DEFAULT_MAX_MANIFEST_DECOMPRESSED_BYTES).await
}
async fn get_manifest_from_disk_with_limit(
cache_dir: &Path,
max_decompressed: usize,
) -> Result<PackageIndex> {
let path = api_manifest_path(cache_dir);
if !path.exists() {
return Err(Error::Cache("No cached manifest found".to_string()));
}
tokio::task::spawn_blocking(move || decode_cached_manifest(&path, max_decompressed))
.await
.map_err(|e| Error::Cache(format!("cache decode task panicked: {}", e)))?
}
fn is_fresh(network: DateTime<FixedOffset>, local: DateTime<FixedOffset>) -> bool {
network <= local
}
async fn load_cached_index(index_dir: &Path, max_decompressed: usize) -> Option<PackageIndex> {
match get_manifest_from_disk_with_limit(index_dir, max_decompressed).await {
Ok(manifest) => Some(manifest),
Err(err) => {
tracing::warn!("Cached manifest unreadable ({}); re-fetching", err);
None
}
}
}
fn decode_cached_manifest(path: &Path, max_decompressed: usize) -> Result<PackageIndex> {
let limit = max_decompressed as u64;
let file = std::fs::File::open(path)?;
let mut decoder = zstd::Decoder::new(file)?;
let mut decompressed_data = Vec::new();
decoder
.by_ref()
.take(limit + 1)
.read_to_end(&mut decompressed_data)?;
if decompressed_data.len() as u64 > limit {
return Err(Error::SizeLimitExceeded {
context: "decompressed cache".to_string(),
limit,
actual: Some(decompressed_data.len() as u64),
});
}
let config = bincode::config::standard().with_limit::<MAX_DECODE_ALLOC>();
let (serializable, _): (SerializedPackageIndex, usize) =
bincode::decode_from_slice(&decompressed_data, config)
.map_err(|e| Error::Cache(format!("failed to decode cached manifest: {}", e)))?;
serializable.try_into()
}
async fn get_manifest_from_network(
http: &Client,
api_url: &str,
max_bytes: Option<u64>,
retry: &RetryPolicy,
progress: &dyn ProgressReporter,
) -> Result<(Vec<Package>, DateTime<FixedOffset>)> {
let handle = progress.begin("Downloading Api Manifest", None);
let result = retry_http(retry, || async {
let response = http.get(api_url).send().await?.error_for_status()?;
let last_modified_str = response
.headers()
.get(header::LAST_MODIFIED)
.ok_or(Error::MissingHeader("Last-Modified".to_string()))?
.to_str()?;
let last_modified = DateTime::parse_from_rfc2822(last_modified_str)
.map_err(|e| Error::InvalidHeader(format!("invalid Last-Modified header: {}", e)))?;
let raw_response_data = read_capped_body(response, max_bytes).await?;
let packages: Vec<Package> = serde_json::from_slice(&raw_response_data)?;
Ok((packages, last_modified))
})
.await;
match result {
Ok((packages, last_modified)) => {
handle.finish(Some(" Downloaded Api Manifest"));
Ok((packages, last_modified))
}
Err(err) => {
handle.finish(None);
Err(err)
}
}
}
async fn read_capped_body(response: reqwest::Response, max_bytes: Option<u64>) -> Result<Vec<u8>> {
let mut cap = max_bytes.map(|max| SizeCap::new("manifest body", max));
if let Some(cap) = &cap {
cap.check_declared(response.content_length())?;
}
let mut stream = response.bytes_stream();
let mut body: Vec<u8> = Vec::new();
while let Some(chunk) = stream.next().await {
let chunk = chunk?;
if let Some(cap) = cap.as_mut() {
cap.add(chunk.len() as u64)?;
}
body.extend_from_slice(&chunk);
}
Ok(body)
}
fn encode_manifest_cache(packages: Vec<Package>) -> Result<(PackageIndex, Vec<u8>)> {
let manifest: PackageIndex = packages.into();
let serializable: SerializedPackageIndex = (&manifest).into();
let binary_data = bincode::encode_to_vec(&serializable, bincode::config::standard())
.map_err(|e| Error::Cache(format!("failed to encode manifest cache: {}", e)))?;
Ok((manifest, binary_data))
}
async fn get_manifest_from_network_and_cache(
cache_dir: &Path,
http: &Client,
api_url: &str,
max_manifest_bytes: Option<u64>,
retry: &RetryPolicy,
progress: &dyn ProgressReporter,
) -> Result<PackageIndex> {
let (packages, last_modified_from_network) =
get_manifest_from_network(http, api_url, max_manifest_bytes, retry, progress).await?;
let (manifest, binary_data) =
tokio::task::spawn_blocking(move || encode_manifest_cache(packages))
.await
.map_err(|e| Error::Cache(format!("cache encode task panicked: {}", e)))??;
write_cache_to_disk(api_manifest_path(cache_dir), &binary_data, true).await?;
if let Err(err) = write_cache_to_disk(
last_modified_path(cache_dir),
last_modified_from_network.to_rfc2822().as_bytes(),
false,
)
.await
{
tracing::warn!(
"Failed to write last_modified marker ({}); cached manifest kept, will re-fetch",
err
);
}
Ok(manifest)
}
async fn local_last_modified(cache_dir: &Path) -> Result<DateTime<FixedOffset>> {
let path = last_modified_path(cache_dir);
if let Ok(file) = fs::File::open(&path).await {
tracing::info!("Last modified file exists and was opened.");
let mut buf = Vec::new();
file
.take(MAX_LAST_MODIFIED_BYTES)
.read_to_end(&mut buf)
.await?;
let contents = String::from_utf8_lossy(&buf);
match DateTime::parse_from_rfc2822(contents.trim()) {
Ok(last_modified) => Ok(last_modified),
Err(err) => {
tracing::warn!(
"Unparseable last_modified marker ({}); treating as stale",
err
);
Ok(epoch())
}
}
} else {
tracing::info!("Last modified file does not exist and was not opened.");
Ok(epoch())
}
}
fn epoch() -> DateTime<FixedOffset> {
let dt = DateTime::from_timestamp(0, 0)
.expect("timestamp 0 is valid")
.naive_utc();
let offset = FixedOffset::east_opt(0).expect("zero offset is valid");
DateTime::<FixedOffset>::from_naive_utc_and_offset(dt, offset)
}
async fn network_last_modified(http: &Client, api_url: &str) -> Result<DateTime<FixedOffset>> {
let response = http.head(api_url).send().await?.error_for_status()?;
let last_modified = response
.headers()
.get(header::LAST_MODIFIED)
.ok_or(Error::MissingHeader(
"Last-Modified for API manifest head request".to_string(),
))?;
let last_modified_date = DateTime::parse_from_rfc2822(last_modified.to_str()?)
.map_err(|e| Error::InvalidHeader(format!("invalid Last-Modified header: {}", e)))?;
Ok(last_modified_date)
}
fn api_manifest_file_exists(cache_dir: &Path) -> bool {
api_manifest_path(cache_dir).exists()
}
fn temp_sibling_path(path: &Path) -> PathBuf {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
let pid = std::process::id();
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("cache");
let tmp_name = format!(".{name}.{pid}.{seq}.tmp");
match path.parent() {
Some(parent) => parent.join(tmp_name),
None => PathBuf::from(tmp_name),
}
}
async fn write_cache_to_disk<T: AsRef<Path>>(
path: T,
contents: &[u8],
use_compression: bool,
) -> Result<()> {
let path = path.as_ref();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).await?;
}
let payload = if use_compression {
let data = contents.to_vec();
tokio::task::spawn_blocking(move || zstd::encode_all(data.as_slice(), 9))
.await
.map_err(|e| Error::Cache(format!("cache encode task panicked: {}", e)))??
} else {
contents.to_vec()
};
let tmp_path = temp_sibling_path(path);
if let Err(err) = write_and_rename(&tmp_path, path, &payload).await {
let _ = fs::remove_file(&tmp_path).await;
return Err(err);
}
Ok(())
}
async fn write_and_rename(tmp_path: &Path, path: &Path, payload: &[u8]) -> Result<()> {
let mut file = fs::File::create(tmp_path).await?;
file.write_all(payload).await?;
file.flush().await?;
file.sync_all().await?;
fs::rename(tmp_path, path).await?;
if let Some(parent) = path.parent()
&& let Ok(dir) = fs::File::open(parent).await
{
let _ = dir.sync_all().await;
}
Ok(())
}
fn parse_content_length(headers: &reqwest::header::HeaderMap) -> Option<u64> {
headers
.get(header::CONTENT_LENGTH)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.parse::<u64>().ok())
}
fn check_complete(content_length: Option<u64>, written: u64) -> Result<()> {
if let Some(expected) = content_length
&& written != expected
{
return Err(Error::IncompleteDownload {
expected,
actual: written,
});
}
Ok(())
}
fn aggregate_download_results(results: Vec<Result<()>>, total: usize) -> Result<()> {
let mut failures = 0;
let mut first_error: Option<String> = None;
for result in results {
if let Err(err) = result {
failures += 1;
tracing::error!("Download error: {:?}", err);
if first_error.is_none() {
first_error = Some(err.to_string());
}
}
}
if failures > 0 {
return Err(Error::Download {
failed: failures,
total,
first: first_error.unwrap_or_default(),
});
}
Ok(())
}
async fn download_files(
urls: HashMap<String, String>,
cache_dir: &Path,
http: &Client,
concurrency: usize,
max_bytes: Option<u64>,
retry: RetryPolicy,
progress: Arc<dyn ProgressReporter>,
) -> Result<()> {
if urls.is_empty() {
tracing::debug!("No files to download");
return Ok(());
}
tracing::debug!("Processing {} mods", urls.len());
let total = urls.len();
let concurrency = concurrency.max(1);
let results: Vec<Result<()>> =
futures::stream::iter(urls.into_iter().map(|(archive_filename, url)| {
let client = http.clone();
let cache_dir = cache_dir.to_path_buf();
let progress = progress.clone();
async move {
download_file(
&client,
&url,
&archive_filename,
&cache_dir,
max_bytes,
&retry,
progress.as_ref(),
)
.await
}
}))
.buffer_unordered(concurrency)
.collect()
.await;
aggregate_download_results(results, total)
}
fn safe_download_name(filename: &str) -> Result<&str> {
let mut components = Path::new(filename).components();
let only = components.next();
let is_single_normal = matches!(only, Some(std::path::Component::Normal(_)));
if !is_single_normal || components.next().is_some() {
return Err(Error::UnsafeName(format!(
"Unsafe download filename: {}",
filename
)));
}
Ok(filename)
}
struct TempFileGuard {
path: Option<PathBuf>,
}
impl TempFileGuard {
fn new(path: PathBuf) -> Self {
Self { path: Some(path) }
}
fn disarm(&mut self) {
self.path = None;
}
}
impl Drop for TempFileGuard {
fn drop(&mut self) {
if let Some(path) = self.path.take() {
let _ = std::fs::remove_file(path);
}
}
}
async fn download_file(
client: &Client,
url: &str,
filename: &str,
cache_dir: &Path,
max_bytes: Option<u64>,
retry: &RetryPolicy,
progress: &dyn ProgressReporter,
) -> Result<()> {
let downloads_directory = cache_dir.join("downloads");
let file_path = downloads_directory.join(safe_download_name(filename)?);
tokio::fs::DirBuilder::new()
.recursive(true)
.create(&downloads_directory)
.await
.map_err(|e| {
io_context(
format!("creating downloads dir {}", downloads_directory.display()),
e,
)
})?;
if file_path.exists() {
tracing::debug!("{} already exists, skipping download", filename);
return Ok(());
}
let tmp_path = temp_sibling_path(&file_path);
let mut tmp_guard = TempFileGuard::new(tmp_path.clone());
let handle = progress.begin(filename, None);
let attempt = retry_http(retry, || async {
handle.set_position(0);
let response = client.get(url).send().await?.error_for_status()?;
let content_length = parse_content_length(response.headers());
let mut cap = max_bytes.map(|max| SizeCap::new("download body", max));
if let Some(cap) = &cap {
cap.check_declared(content_length)?;
}
if let Some(total) = content_length {
handle.set_length(total);
}
let mut response_data = response.bytes_stream();
let mut file = tokio::fs::File::create(&tmp_path).await.map_err(|e| {
io_context(
format!("creating temp download file {}", tmp_path.display()),
e,
)
})?;
let mut written: u64 = 0;
while let Some(result) = response_data.next().await {
let chunk = result?;
written += chunk.len() as u64;
if let Some(cap) = cap.as_mut() {
cap.add(chunk.len() as u64)?;
}
file.write_all(&chunk).await?;
handle.inc(chunk.len() as u64);
}
file.flush().await?;
file.sync_all().await?;
check_complete(content_length, written)
})
.await;
if let Err(err) = attempt {
handle.finish(None);
return Err(err);
}
if let Err(err) = tokio::fs::rename(&tmp_path, &file_path).await {
handle.finish(None);
return Err(io_context(
format!("renaming {} to {}", tmp_path.display(), file_path.display()),
err,
));
}
tmp_guard.disarm();
handle.finish(None);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::progress::{NoopProgress, ProgressHandle};
use mockito::Server;
use std::fs::File;
use std::io::Write;
use tempfile::tempdir;
use time::OffsetDateTime;
use tokio::runtime::Runtime;
fn test_http() -> Client {
Client::builder()
.timeout(Duration::from_secs(60))
.build()
.unwrap()
}
#[test]
fn test_api_manifest_file_exists() {
let temp_dir = tempdir().unwrap();
let cache_dir = temp_dir.path();
assert!(!api_manifest_file_exists(cache_dir));
let _ = File::create(cache_dir.join(API_MANIFEST_FILENAME)).unwrap();
assert!(api_manifest_file_exists(cache_dir));
}
#[test]
fn test_path_construction() {
let cache_dir = Path::new("/some/cache/dir");
let last_modified = last_modified_path(cache_dir);
let api_manifest = api_manifest_path(cache_dir);
assert_eq!(last_modified, cache_dir.join(LAST_MODIFIED_FILENAME));
assert_eq!(api_manifest, cache_dir.join(API_MANIFEST_FILENAME));
}
#[test]
fn test_builder_composes_url() {
let temp_dir = tempdir().unwrap();
let client = ThunderstoreClient::builder()
.base_url("https://stacklands.thunderstore.io")
.community("valheim")
.cache_dir(temp_dir.path())
.build()
.unwrap();
assert_eq!(
client.package_index_url(),
"https://stacklands.thunderstore.io/c/valheim/api/v1/package/"
);
}
#[test]
fn test_builder_requires_cache_dir() {
let result = ThunderstoreClient::builder().community("valheim").build();
assert!(matches!(result, Err(Error::Config(_))));
}
#[test]
fn parse_content_length_reads_valid_and_ignores_missing_or_bad() {
use reqwest::header::{CONTENT_LENGTH, HeaderMap, HeaderValue};
let mut h = HeaderMap::new();
assert_eq!(parse_content_length(&h), None);
h.insert(CONTENT_LENGTH, HeaderValue::from_static("2048"));
assert_eq!(parse_content_length(&h), Some(2048));
h.insert(CONTENT_LENGTH, HeaderValue::from_static("not-a-number"));
assert_eq!(parse_content_length(&h), None);
}
#[test]
fn aggregate_download_results_all_ok_is_ok() {
assert!(aggregate_download_results(vec![Ok(()), Ok(())], 2).is_ok());
assert!(aggregate_download_results(vec![], 0).is_ok());
}
#[test]
fn aggregate_download_results_reports_count_and_first_error() {
let results = vec![
Ok(()),
Err(Error::UnsafeName("bad".to_string())),
Err(Error::IncompleteDownload {
expected: 5,
actual: 2,
}),
];
let err = aggregate_download_results(results, 3).unwrap_err();
match err {
Error::Download {
failed,
total,
first,
} => {
assert_eq!(failed, 2);
assert_eq!(total, 3);
assert!(first.contains("unsafe name"), "first was: {first}");
}
other => panic!("unexpected error: {other}"),
}
}
#[test]
fn compose_index_url_prefers_override_then_builds_from_community() {
assert_eq!(
compose_index_url("https://thunderstore.io", Some("valheim"), None).unwrap(),
"https://thunderstore.io/c/valheim/api/v1/package/"
);
assert_eq!(
compose_index_url("https://thunderstore.io/", Some("valheim"), None).unwrap(),
"https://thunderstore.io/c/valheim/api/v1/package/"
);
assert_eq!(
compose_index_url("https://ignored", None, Some("https://custom/pkg/")).unwrap(),
"https://custom/pkg/"
);
assert!(matches!(
compose_index_url("https://thunderstore.io", None, None),
Err(Error::Config(_))
));
assert!(matches!(
compose_index_url("https://thunderstore.io", Some("bad/slug"), None),
Err(Error::Config(_))
));
}
#[test]
fn resolve_decompressed_cap_precedence() {
assert_eq!(resolve_decompressed_cap(Some(42), Some(100)), 42);
assert_eq!(resolve_decompressed_cap(None, Some(100)), 100);
assert_eq!(
resolve_decompressed_cap(None, None),
DEFAULT_MAX_MANIFEST_DECOMPRESSED_BYTES
);
}
#[test]
fn is_fresh_compares_last_modified() {
let older = DateTime::parse_from_rfc2822("Wed, 21 Feb 2024 15:30:45 +0000").unwrap();
let newer = DateTime::parse_from_rfc2822("Thu, 22 Feb 2024 15:30:45 +0000").unwrap();
assert!(is_fresh(older, older));
assert!(is_fresh(older, newer));
assert!(!is_fresh(newer, older));
}
#[test]
fn encode_manifest_cache_builds_index_and_nonempty_blob() {
let (index, bytes) = encode_manifest_cache(vec![sample_package("X", "O-X", "O")]).unwrap();
assert_eq!(index.len(), 1);
assert!(!bytes.is_empty());
}
#[test]
fn check_complete_flags_short_and_accepts_exact_or_unknown() {
assert!(check_complete(Some(100), 100).is_ok());
assert!(check_complete(None, 42).is_ok());
let err = check_complete(Some(100), 50).unwrap_err();
assert!(matches!(
err,
Error::IncompleteDownload {
expected: 100,
actual: 50,
}
));
}
#[test]
fn safe_download_name_accepts_plain_and_rejects_traversal() {
assert_eq!(
safe_download_name("Author-Mod-1.0.0.zip").unwrap(),
"Author-Mod-1.0.0.zip"
);
assert!(matches!(
safe_download_name("../escape.zip"),
Err(Error::UnsafeName(_))
));
assert!(matches!(
safe_download_name("/etc/passwd"),
Err(Error::UnsafeName(_))
));
assert!(matches!(
safe_download_name("a/b.zip"),
Err(Error::UnsafeName(_))
));
}
#[test]
fn test_write_cache_to_disk() {
let temp_dir = tempdir().unwrap();
let cache_path = temp_dir.path().join("test_cache.txt");
let test_data = b"Test cache data";
let rt = Runtime::new().unwrap();
let result = rt.block_on(write_cache_to_disk(&cache_path, test_data, false));
assert!(result.is_ok());
assert!(cache_path.exists());
let content = std::fs::read(&cache_path).unwrap();
assert_eq!(content, test_data);
let new_data = b"Updated cache data";
let result = rt.block_on(write_cache_to_disk(&cache_path, new_data, false));
assert!(result.is_ok());
let content = std::fs::read(&cache_path).unwrap();
assert_eq!(content, new_data);
}
#[test]
fn test_write_cache_to_disk_creates_directories() {
let temp_dir = tempdir().unwrap();
let nonexistent_subdir = temp_dir.path().join("subdir1/subdir2");
let cache_path = nonexistent_subdir.join("test_cache.txt");
assert!(!nonexistent_subdir.exists());
assert!(!cache_path.exists());
let test_data = b"Test cache data";
let rt = Runtime::new().unwrap();
let result = rt.block_on(write_cache_to_disk(&cache_path, test_data, false));
assert!(result.is_ok());
assert!(
nonexistent_subdir.exists(),
"Directory structure should be created"
);
assert!(cache_path.exists(), "File should be created");
let content = std::fs::read(&cache_path).unwrap();
assert_eq!(content, test_data, "File should contain the expected data");
}
#[test]
fn test_write_cache_to_disk_with_compression() {
let temp_dir = tempdir().unwrap();
let api_manifest_path = temp_dir.path().join("test_compressed.bin");
let package = sample_package("TestMod", "TestOwner-TestMod", "TestOwner");
let packages = vec![package];
let binary_data =
bincode::serde::encode_to_vec(&packages, bincode::config::standard()).unwrap();
let rt = Runtime::new().unwrap();
let result = rt.block_on(write_cache_to_disk(&api_manifest_path, &binary_data, true));
assert!(result.is_ok());
assert!(api_manifest_path.exists());
let compressed_data = std::fs::read(&api_manifest_path).unwrap();
let decompressed_data = zstd::decode_all(compressed_data.as_slice()).unwrap();
let (decoded, _): (Vec<Package>, usize) =
bincode::serde::decode_from_slice(&decompressed_data, bincode::config::standard()).unwrap();
assert_eq!(decoded.len(), 1);
assert_eq!(decoded[0].full_name, Some("TestOwner-TestMod".to_string()));
}
#[test]
fn test_local_last_modified() {
let temp_dir = tempdir().unwrap();
let cache_dir = temp_dir.path();
let rt = Runtime::new().unwrap();
{
let result = rt.block_on(local_last_modified(cache_dir));
assert!(result.is_ok());
}
let test_date = "Wed, 21 Feb 2024 15:30:45 GMT";
let mut file = File::create(cache_dir.join(LAST_MODIFIED_FILENAME)).unwrap();
file.write_all(test_date.as_bytes()).unwrap();
{
let result = rt.block_on(local_last_modified(cache_dir));
assert!(result.is_ok());
}
}
#[test]
fn test_get_manifest_from_disk() {
let temp_dir = tempdir().unwrap();
let cache_dir = temp_dir.path();
let package = sample_package("ModA", "Owner-ModA", "Owner");
let packages = vec![package];
let interned_manifest: PackageIndex = packages.into();
let serializable: SerializedPackageIndex = (&interned_manifest).into();
let binary_data = bincode::encode_to_vec(&serializable, bincode::config::standard()).unwrap();
let compressed_data = zstd::encode_all(binary_data.as_slice(), 9).unwrap();
let mut file = File::create(cache_dir.join(API_MANIFEST_FILENAME)).unwrap();
file.write_all(&compressed_data).unwrap();
let rt = Runtime::new().unwrap();
let manifest = rt.block_on(get_manifest_from_disk(cache_dir)).unwrap();
assert_eq!(manifest.len(), 1);
assert_eq!(
manifest.resolve_full_name_at(0),
Some("Owner-ModA".to_string())
);
}
#[test]
fn test_get_manifest_from_disk_error() {
let temp_dir = tempdir().unwrap();
let cache_dir = temp_dir.path();
let mut file = File::create(cache_dir.join(API_MANIFEST_FILENAME)).unwrap();
file
.write_all(b"This is not valid compressed data")
.unwrap();
let rt = Runtime::new().unwrap();
let result = rt.block_on(get_manifest_from_disk(cache_dir));
assert!(result.is_err());
}
#[test]
fn test_network_last_modified() {
let mut server = Server::new();
let test_date = "Wed, 21 Feb 2024 15:30:45 GMT";
let mock = server
.mock("HEAD", "/c/valheim/api/v1/package/")
.with_status(200)
.with_header("Last-Modified", test_date)
.create();
let api_url = format!("{}/c/valheim/api/v1/package/", server.url());
let rt = Runtime::new().unwrap();
let result = rt.block_on(network_last_modified(&test_http(), &api_url));
assert!(result.is_ok());
if let Ok(parsed_date) = result {
let expected_date = DateTime::parse_from_rfc2822(test_date).unwrap();
assert_eq!(parsed_date, expected_date);
}
mock.assert();
}
#[test]
fn test_get_manifest_from_network() {
let mut server = Server::new();
let test_date = "Wed, 21 Feb 2024 15:30:45 GMT";
let mock = server
.mock("GET", "/c/valheim/api/v1/package/")
.with_status(200)
.with_header("Content-Type", "application/json")
.with_header("Last-Modified", test_date)
.with_body(sample_manifest_json())
.create();
let api_url = format!("{}/c/valheim/api/v1/package/", server.url());
let rt = Runtime::new().unwrap();
let result = rt.block_on(get_manifest_from_network(
&test_http(),
&api_url,
None,
&RetryPolicy::none(),
&NoopProgress,
));
assert!(result.is_ok());
if let Ok((packages, last_modified)) = result {
assert_eq!(packages.len(), 1);
assert_eq!(packages[0].full_name, Some("Owner-ModA".to_string()));
let expected_date = DateTime::parse_from_rfc2822(test_date).unwrap();
assert_eq!(last_modified, expected_date);
}
mock.assert();
}
fn seed_index_cache(index_dir: &Path, last_modified: &str) {
std::fs::create_dir_all(index_dir).unwrap();
let package = sample_package("CachedMod", "CachedOwner-CachedMod", "CachedOwner");
let interned_manifest: PackageIndex = vec![package].into();
let serializable: SerializedPackageIndex = (&interned_manifest).into();
let binary_data = bincode::encode_to_vec(&serializable, bincode::config::standard()).unwrap();
let compressed_data = zstd::encode_all(binary_data.as_slice(), 9).unwrap();
File::create(index_dir.join(API_MANIFEST_FILENAME))
.unwrap()
.write_all(&compressed_data)
.unwrap();
File::create(index_dir.join(LAST_MODIFIED_FILENAME))
.unwrap()
.write_all(last_modified.as_bytes())
.unwrap();
}
#[test]
fn test_get_manifest_loads_fresh_cache() {
let mut server = Server::new();
let temp_dir = tempdir().unwrap();
let cache_dir = temp_dir.path();
let mock = server
.mock("HEAD", "/pkg/")
.with_status(200)
.with_header("Last-Modified", "Wed, 21 Feb 2024 15:30:45 GMT")
.create();
let api_url = format!("{}/pkg/", server.url());
let client = ThunderstoreClient::builder()
.package_index_url(&api_url)
.cache_dir(cache_dir)
.build()
.unwrap();
let now = chrono::Utc::now().with_timezone(&chrono::FixedOffset::east_opt(0).unwrap());
seed_index_cache(&client.index_cache_dir(), &now.to_rfc2822());
let rt = Runtime::new().unwrap();
let manifest = rt.block_on(client.get_manifest()).unwrap();
assert_eq!(manifest.len(), 1);
assert_eq!(
manifest.resolve_full_name_at(0),
Some("CachedOwner-CachedMod".to_string())
);
mock.assert();
}
#[test]
fn test_get_manifest_falls_back_to_cache_when_head_fails() {
let mut server = Server::new();
let temp_dir = tempdir().unwrap();
let cache_dir = temp_dir.path();
let mock = server
.mock("HEAD", "/c/valheim/api/v1/package/")
.with_status(500)
.create();
let api_url = format!("{}/c/valheim/api/v1/package/", server.url());
let client = ThunderstoreClient::builder()
.package_index_url(&api_url)
.cache_dir(cache_dir)
.build()
.unwrap();
seed_index_cache(&client.index_cache_dir(), "Wed, 21 Feb 2024 15:30:45 GMT");
let rt = Runtime::new().unwrap();
let result = rt.block_on(client.get_manifest());
assert!(result.is_ok());
let manifest = result.unwrap();
assert_eq!(manifest.len(), 1);
assert_eq!(
manifest.resolve_full_name_at(0),
Some("CachedOwner-CachedMod".to_string())
);
mock.assert();
}
#[test]
fn test_refresh_index_bypasses_cache_and_downloads() {
let mut server = Server::new();
let temp_dir = tempdir().unwrap();
let cache_dir = temp_dir.path();
let get_mock = server
.mock("GET", "/pkg/")
.with_status(200)
.with_header("Content-Type", "application/json")
.with_header("Last-Modified", "Wed, 21 Feb 2024 15:30:45 GMT")
.with_body(sample_manifest_json())
.create();
let api_url = format!("{}/pkg/", server.url());
let client = ThunderstoreClient::builder()
.package_index_url(&api_url)
.cache_dir(cache_dir)
.build()
.unwrap();
let now = chrono::Utc::now().with_timezone(&chrono::FixedOffset::east_opt(0).unwrap());
seed_index_cache(&client.index_cache_dir(), &now.to_rfc2822());
let rt = Runtime::new().unwrap();
let manifest = rt.block_on(client.refresh_index()).unwrap();
assert_eq!(
manifest.resolve_full_name_at(0),
Some("Owner-ModA".to_string())
);
get_mock.assert();
}
#[test]
fn test_get_manifest_downloads_when_cache_is_stale() {
let mut server = Server::new();
let temp_dir = tempdir().unwrap();
let cache_dir = temp_dir.path();
let head_mock = server
.mock("HEAD", "/pkg/")
.with_status(200)
.with_header("Last-Modified", "Wed, 28 Feb 2024 15:30:45 GMT")
.create();
let get_mock = server
.mock("GET", "/pkg/")
.with_status(200)
.with_header("Content-Type", "application/json")
.with_header("Last-Modified", "Wed, 28 Feb 2024 15:30:45 GMT")
.with_body(sample_manifest_json())
.create();
let api_url = format!("{}/pkg/", server.url());
let client = ThunderstoreClient::builder()
.package_index_url(&api_url)
.cache_dir(cache_dir)
.build()
.unwrap();
seed_index_cache(&client.index_cache_dir(), "Wed, 21 Feb 2024 15:30:45 GMT");
let rt = Runtime::new().unwrap();
let manifest = rt.block_on(client.get_manifest()).unwrap();
assert_eq!(
manifest.resolve_full_name_at(0),
Some("Owner-ModA".to_string())
);
head_mock.assert();
get_mock.assert();
}
#[test]
fn test_download_file_skips_existing_without_refetch() {
let mut server = Server::new();
let temp_dir = tempdir().unwrap();
let cache_dir = temp_dir.path();
let mock = server
.mock("GET", "/mod.zip")
.with_status(200)
.with_body("zip-bytes")
.expect(1)
.create();
let file_url = format!("{}/mod.zip", server.url());
let rt = Runtime::new().unwrap();
rt.block_on(download_file(
&test_http(),
&file_url,
"mod.zip",
cache_dir,
None,
&RetryPolicy::none(),
&NoopProgress,
))
.unwrap();
rt.block_on(download_file(
&test_http(),
&file_url,
"mod.zip",
cache_dir,
None,
&RetryPolicy::none(),
&NoopProgress,
))
.unwrap();
mock.assert();
}
#[test]
fn test_head_error_status_is_not_trusted_as_freshness() {
let mut server = Server::new();
let temp_dir = tempdir().unwrap();
let cache_dir = temp_dir.path();
let mock = server
.mock("HEAD", "/pkg/")
.with_status(500)
.with_header("Last-Modified", "Wed, 21 Feb 2099 15:30:45 GMT")
.create();
let api_url = format!("{}/pkg/", server.url());
let client = ThunderstoreClient::builder()
.package_index_url(&api_url)
.cache_dir(cache_dir)
.build()
.unwrap();
seed_index_cache(&client.index_cache_dir(), "Wed, 21 Feb 2024 15:30:45 GMT");
let rt = Runtime::new().unwrap();
let manifest = rt.block_on(client.get_manifest()).unwrap();
assert_eq!(manifest.len(), 1);
mock.assert();
}
#[test]
fn test_get_manifest_refetches_on_unsupported_cache_version() {
let mut server = Server::new();
let temp_dir = tempdir().unwrap();
let cache_dir = temp_dir.path();
let test_date = "Wed, 21 Feb 2024 15:30:45 GMT";
let head = server
.mock("HEAD", "/pkg/")
.with_status(200)
.with_header("Last-Modified", test_date)
.create();
let get = server
.mock("GET", "/pkg/")
.with_status(200)
.with_header("Content-Type", "application/json")
.with_header("Last-Modified", test_date)
.with_body(sample_manifest_json())
.create();
let api_url = format!("{}/pkg/", server.url());
let client = ThunderstoreClient::builder()
.package_index_url(&api_url)
.cache_dir(cache_dir)
.build()
.unwrap();
let index_dir = client.index_cache_dir();
std::fs::create_dir_all(&index_dir).unwrap();
let interned: PackageIndex = vec![sample_package("X", "O-X", "O")].into();
let mut serializable: SerializedPackageIndex = (&interned).into();
serializable.format_version = 99;
let binary_data = bincode::encode_to_vec(&serializable, bincode::config::standard()).unwrap();
let compressed = zstd::encode_all(binary_data.as_slice(), 9).unwrap();
File::create(index_dir.join(API_MANIFEST_FILENAME))
.unwrap()
.write_all(&compressed)
.unwrap();
let now = chrono::Utc::now().with_timezone(&chrono::FixedOffset::east_opt(0).unwrap());
File::create(index_dir.join(LAST_MODIFIED_FILENAME))
.unwrap()
.write_all(now.to_rfc2822().as_bytes())
.unwrap();
let rt = Runtime::new().unwrap();
let manifest = rt.block_on(client.get_manifest()).unwrap();
assert_eq!(
manifest.resolve_full_name_at(0),
Some("Owner-ModA".to_string())
);
head.assert();
get.assert();
}
#[test]
fn test_builder_rejects_invalid_community() {
let temp_dir = tempdir().unwrap();
let result = ThunderstoreClient::builder()
.community("../../etc")
.cache_dir(temp_dir.path())
.build();
assert!(matches!(result, Err(Error::Config(_))));
}
#[test]
fn test_index_cache_dir_is_namespaced_per_community() {
let temp_dir = tempdir().unwrap();
let cache_dir = temp_dir.path();
let a = ThunderstoreClient::builder()
.community("valheim")
.cache_dir(cache_dir)
.build()
.unwrap();
let b = ThunderstoreClient::builder()
.community("lethal-company")
.cache_dir(cache_dir)
.build()
.unwrap();
assert_ne!(a.index_cache_dir(), b.index_cache_dir());
assert!(a.index_cache_dir().starts_with(cache_dir));
assert!(b.index_cache_dir().starts_with(cache_dir));
}
#[test]
fn test_local_last_modified_treats_corrupt_marker_as_stale() {
let temp_dir = tempdir().unwrap();
let cache_dir = temp_dir.path();
File::create(cache_dir.join(LAST_MODIFIED_FILENAME))
.unwrap()
.write_all(b"not a date")
.unwrap();
let rt = Runtime::new().unwrap();
let result = rt.block_on(local_last_modified(cache_dir)).unwrap();
assert_eq!(result, epoch());
}
#[test]
fn test_get_manifest_from_network_rejects_oversize_body() {
let mut server = Server::new();
let test_date = "Wed, 21 Feb 2024 15:30:45 GMT";
let mock = server
.mock("GET", "/pkg/")
.with_status(200)
.with_header("Last-Modified", test_date)
.with_body(sample_manifest_json())
.create();
let api_url = format!("{}/pkg/", server.url());
let rt = Runtime::new().unwrap();
let result = rt.block_on(get_manifest_from_network(
&test_http(),
&api_url,
Some(8),
&RetryPolicy::none(),
&NoopProgress,
));
assert!(matches!(result, Err(Error::SizeLimitExceeded { .. })));
mock.assert();
}
#[test]
fn test_get_manifest_from_disk_rejects_oversized_decompressed() {
let temp_dir = tempdir().unwrap();
let cache_dir = temp_dir.path();
let interned: PackageIndex = vec![sample_package("X", "O-X", "O")].into();
let serializable: SerializedPackageIndex = (&interned).into();
let binary_data = bincode::encode_to_vec(&serializable, bincode::config::standard()).unwrap();
let compressed = zstd::encode_all(binary_data.as_slice(), 9).unwrap();
File::create(cache_dir.join(API_MANIFEST_FILENAME))
.unwrap()
.write_all(&compressed)
.unwrap();
let rt = Runtime::new().unwrap();
let result = rt.block_on(get_manifest_from_disk_with_limit(cache_dir, 4));
assert!(matches!(result, Err(Error::SizeLimitExceeded { .. })));
}
#[test]
fn test_read_cap_defaults_to_download_cap() {
let temp_dir = tempdir().unwrap();
let client = ThunderstoreClient::builder()
.community("valheim")
.cache_dir(temp_dir.path())
.build()
.unwrap();
assert_eq!(
client.max_manifest_decompressed_bytes as u64,
DEFAULT_MAX_MANIFEST_BYTES
);
let client = ThunderstoreClient::builder()
.community("valheim")
.cache_dir(temp_dir.path())
.manifest_max_bytes(900 * 1024 * 1024)
.build()
.unwrap();
assert_eq!(
client.max_manifest_decompressed_bytes as u64,
900 * 1024 * 1024
);
let client = ThunderstoreClient::builder()
.community("valheim")
.cache_dir(temp_dir.path())
.manifest_max_bytes(900 * 1024 * 1024)
.manifest_max_decompressed_bytes(64 * 1024 * 1024)
.build()
.unwrap();
assert_eq!(client.max_manifest_decompressed_bytes, 64 * 1024 * 1024);
}
#[test]
fn test_large_cache_roundtrips_within_cap_and_rejects_below() {
let temp_dir = tempdir().unwrap();
let cache_dir = temp_dir.path();
let packages: Vec<Package> = (0..500)
.map(|i| {
sample_package(
&format!("Mod{i}"),
&format!("Owner{i}-Mod{i}"),
&format!("Owner{i}"),
)
})
.collect();
let count = packages.len();
let interned: PackageIndex = packages.into();
let serializable: SerializedPackageIndex = (&interned).into();
let binary_data = bincode::encode_to_vec(&serializable, bincode::config::standard()).unwrap();
let decompressed_len = binary_data.len();
let compressed = zstd::encode_all(binary_data.as_slice(), 9).unwrap();
File::create(cache_dir.join(API_MANIFEST_FILENAME))
.unwrap()
.write_all(&compressed)
.unwrap();
let rt = Runtime::new().unwrap();
let manifest = rt
.block_on(get_manifest_from_disk_with_limit(
cache_dir,
decompressed_len,
))
.unwrap();
assert_eq!(manifest.len(), count);
assert!(decompressed_len < DEFAULT_MAX_MANIFEST_DECOMPRESSED_BYTES);
let manifest = rt
.block_on(get_manifest_from_disk_with_limit(
cache_dir,
DEFAULT_MAX_MANIFEST_DECOMPRESSED_BYTES,
))
.unwrap();
assert_eq!(manifest.len(), count);
let result = rt.block_on(get_manifest_from_disk_with_limit(
cache_dir,
decompressed_len - 1,
));
assert!(matches!(result, Err(Error::SizeLimitExceeded { .. })));
}
#[test]
fn test_get_manifest_from_network_and_cache() {
let mut server = Server::new();
let test_date = "Wed, 21 Feb 2024 15:30:45 GMT";
let mock = server
.mock("GET", "/c/valheim/api/v1/package/")
.with_status(200)
.with_header("Content-Type", "application/json")
.with_header("Last-Modified", test_date)
.with_body(sample_manifest_json())
.create();
let api_url = format!("{}/c/valheim/api/v1/package/", server.url());
let temp_dir = tempdir().unwrap();
let cache_dir = temp_dir.path();
let rt = Runtime::new().unwrap();
let result = rt.block_on(get_manifest_from_network_and_cache(
cache_dir,
&test_http(),
&api_url,
None,
&RetryPolicy::none(),
&NoopProgress,
));
assert!(result.is_ok());
if let Ok(manifest) = result {
assert_eq!(manifest.len(), 1);
assert_eq!(
manifest.resolve_full_name_at(0),
Some("Owner-ModA".to_string())
);
assert!(last_modified_path(cache_dir).exists());
assert!(api_manifest_path(cache_dir).exists());
let last_mod_content = std::fs::read_to_string(last_modified_path(cache_dir)).unwrap();
assert!(last_mod_content.contains("21 Feb 2024 15:30:45"));
}
mock.assert();
}
#[test]
fn test_download_file() {
let mut server = Server::new();
let temp_dir = tempdir().unwrap();
let cache_dir = temp_dir.path();
let test_data = b"This is test file content";
let content_length = test_data.len();
let _mock = server
.mock("GET", "/test-file.zip")
.with_status(200)
.with_header("Content-Type", "application/zip")
.with_header("Content-Length", &content_length.to_string())
.with_body(test_data)
.create();
let file_url = format!("{}/test-file.zip", server.url());
let filename = "test-file.zip".to_string();
let rt = Runtime::new().unwrap();
let client = test_http();
let result = rt.block_on(download_file(
&client,
&file_url,
&filename,
cache_dir,
None,
&RetryPolicy::none(),
&NoopProgress,
));
assert!(result.is_ok());
let downloads_dir = cache_dir.join("downloads");
assert!(downloads_dir.exists());
let downloaded_file = downloads_dir.join(&filename);
assert!(downloaded_file.exists());
let file_content = std::fs::read(&downloaded_file).unwrap();
assert_eq!(file_content, test_data);
let result2 = rt.block_on(download_file(
&client,
&file_url,
&filename,
cache_dir,
None,
&RetryPolicy::none(),
&NoopProgress,
));
assert!(result2.is_ok());
}
#[test]
fn test_download_file_without_content_length_succeeds() {
let mut server = Server::new();
let temp_dir = tempdir().unwrap();
let cache_dir = temp_dir.path();
let test_data = b"This is test file content";
let mock = server
.mock("GET", "/test-file.zip")
.with_status(200)
.with_header("Content-Type", "application/zip")
.with_body(test_data)
.create();
let file_url = format!("{}/test-file.zip", server.url());
let filename = "test-file-no-header.zip".to_string();
let rt = Runtime::new().unwrap();
let client = test_http();
let result = rt.block_on(download_file(
&client,
&file_url,
&filename,
cache_dir,
None,
&RetryPolicy::none(),
&NoopProgress,
));
assert!(result.is_ok());
let downloaded = cache_dir.join("downloads").join(&filename);
assert!(downloaded.exists());
assert_eq!(std::fs::read(&downloaded).unwrap(), test_data);
mock.assert();
}
#[test]
fn test_download_files() {
let mut server = Server::new();
let temp_dir = tempdir().unwrap();
let cache_dir = temp_dir.path();
let test_data1 = b"This is test file 1 content";
let content_length1 = test_data1.len();
let test_data2 = b"This is test file 2 content - longer content";
let content_length2 = test_data2.len();
let mock1 = server
.mock("GET", "/file1.zip")
.with_status(200)
.with_header("Content-Type", "application/zip")
.with_header("Content-Length", &content_length1.to_string())
.with_body(test_data1)
.create();
let mock2 = server
.mock("GET", "/file2.zip")
.with_status(200)
.with_header("Content-Type", "application/zip")
.with_header("Content-Length", &content_length2.to_string())
.with_body(test_data2)
.create();
let mut urls = HashMap::new();
urls.insert(
"file1.zip".to_string(),
format!("{}/file1.zip", server.url()),
);
urls.insert(
"file2.zip".to_string(),
format!("{}/file2.zip", server.url()),
);
let rt = Runtime::new().unwrap();
let result = rt.block_on(download_files(
urls,
cache_dir,
&test_http(),
2,
None,
RetryPolicy::none(),
Arc::new(NoopProgress),
));
assert!(result.is_ok());
let downloads_dir = cache_dir.join("downloads");
assert!(downloads_dir.exists());
let file1 = downloads_dir.join("file1.zip");
let file2 = downloads_dir.join("file2.zip");
assert!(file1.exists());
assert!(file2.exists());
let file1_content = std::fs::read(&file1).unwrap();
let file2_content = std::fs::read(&file2).unwrap();
assert_eq!(file1_content, test_data1);
assert_eq!(file2_content, test_data2);
mock1.assert();
mock2.assert();
}
#[test]
fn test_download_files_empty() {
let temp_dir = tempdir().unwrap();
let cache_dir = temp_dir.path();
let urls = HashMap::new();
let rt = Runtime::new().unwrap();
let result = rt.block_on(download_files(
urls,
cache_dir,
&test_http(),
2,
None,
RetryPolicy::none(),
Arc::new(NoopProgress),
));
assert!(result.is_ok());
}
#[test]
fn test_download_files_reports_failure() {
let mut server = Server::new();
let temp_dir = tempdir().unwrap();
let cache_dir = temp_dir.path();
let mock = server.mock("GET", "/missing.zip").with_status(500).create();
let mut urls = HashMap::new();
urls.insert(
"missing.zip".to_string(),
format!("{}/missing.zip", server.url()),
);
let rt = Runtime::new().unwrap();
let result = rt.block_on(download_files(
urls,
cache_dir,
&test_http(),
2,
None,
RetryPolicy::none(),
Arc::new(NoopProgress),
));
assert!(result.is_err());
mock.assert();
}
#[test]
fn test_download_file_rejects_unsafe_filename() {
let temp_dir = tempdir().unwrap();
let cache_dir = temp_dir.path();
let rt = Runtime::new().unwrap();
let result = rt.block_on(download_file(
&test_http(),
"http://example.invalid/x.zip",
"../escape.zip",
cache_dir,
None,
&RetryPolicy::none(),
&NoopProgress,
));
assert!(result.is_err());
}
#[test]
fn test_download_file_rejects_declared_oversize() {
let mut server = Server::new();
let temp_dir = tempdir().unwrap();
let cache_dir = temp_dir.path();
let body = vec![0u8; 100];
let mock = server
.mock("GET", "/big.zip")
.with_status(200)
.with_header("Content-Length", "100")
.with_body(&body)
.create();
let file_url = format!("{}/big.zip", server.url());
let rt = Runtime::new().unwrap();
let result = rt.block_on(download_file(
&test_http(),
&file_url,
"big.zip",
cache_dir,
Some(8),
&RetryPolicy::none(),
&NoopProgress,
));
assert!(matches!(result, Err(Error::SizeLimitExceeded { .. })));
assert!(!cache_dir.join("downloads").join("big.zip").exists());
mock.assert();
}
#[test]
fn test_download_file_rejects_streamed_oversize() {
let mut server = Server::new();
let temp_dir = tempdir().unwrap();
let cache_dir = temp_dir.path();
let mock = server
.mock("GET", "/stream.zip")
.with_status(200)
.with_chunked_body(|w| w.write_all(&[0u8; 100]))
.create();
let file_url = format!("{}/stream.zip", server.url());
let rt = Runtime::new().unwrap();
let result = rt.block_on(download_file(
&test_http(),
&file_url,
"stream.zip",
cache_dir,
Some(8),
&RetryPolicy::none(),
&NoopProgress,
));
assert!(matches!(result, Err(Error::SizeLimitExceeded { .. })));
let downloads_dir = cache_dir.join("downloads");
assert!(!downloads_dir.join("stream.zip").exists());
let leftover = std::fs::read_dir(&downloads_dir)
.unwrap()
.filter_map(|e| e.ok())
.any(|e| e.file_name().to_string_lossy().contains(".tmp"));
assert!(!leftover, "no .tmp temp files should remain");
mock.assert();
}
fn fast_retry(max_retries: u32) -> RetryPolicy {
RetryPolicy {
max_retries,
base_delay: Duration::ZERO,
max_delay: Duration::ZERO,
}
}
#[test]
fn test_download_file_retries_transient_5xx() {
let mut server = Server::new();
let temp_dir = tempdir().unwrap();
let cache_dir = temp_dir.path();
let mock = server
.mock("GET", "/flaky.zip")
.with_status(500)
.expect(3)
.create();
let file_url = format!("{}/flaky.zip", server.url());
let rt = Runtime::new().unwrap();
let result = rt.block_on(download_file(
&test_http(),
&file_url,
"flaky.zip",
cache_dir,
None,
&fast_retry(2),
&NoopProgress,
));
assert!(result.is_err());
mock.assert();
}
#[test]
fn test_download_file_does_not_retry_4xx() {
let mut server = Server::new();
let temp_dir = tempdir().unwrap();
let cache_dir = temp_dir.path();
let mock = server
.mock("GET", "/missing.zip")
.with_status(404)
.expect(1)
.create();
let file_url = format!("{}/missing.zip", server.url());
let rt = Runtime::new().unwrap();
let result = rt.block_on(download_file(
&test_http(),
&file_url,
"missing.zip",
cache_dir,
None,
&fast_retry(5),
&NoopProgress,
));
assert!(result.is_err());
mock.assert();
}
struct RecordingProgress {
positions: Arc<std::sync::Mutex<Vec<u64>>>,
}
impl RecordingProgress {
fn new() -> Self {
Self {
positions: Arc::new(std::sync::Mutex::new(Vec::new())),
}
}
}
impl ProgressReporter for RecordingProgress {
fn begin(&self, _label: &str, _total: Option<u64>) -> Box<dyn ProgressHandle> {
Box::new(RecordingHandle {
positions: self.positions.clone(),
})
}
}
struct RecordingHandle {
positions: Arc<std::sync::Mutex<Vec<u64>>>,
}
impl ProgressHandle for RecordingHandle {
fn inc(&self, _delta: u64) {}
fn set_position(&self, position: u64) {
self.positions.lock().unwrap().push(position);
}
fn set_message(&self, _msg: &str) {}
fn finish(&self, _msg: Option<&str>) {}
}
#[test]
fn test_download_file_resets_progress_each_attempt() {
let mut server = Server::new();
let temp_dir = tempdir().unwrap();
let cache_dir = temp_dir.path();
let mock = server
.mock("GET", "/flaky.zip")
.with_status(500)
.expect(3)
.create();
let file_url = format!("{}/flaky.zip", server.url());
let progress = RecordingProgress::new();
let rt = Runtime::new().unwrap();
let result = rt.block_on(download_file(
&test_http(),
&file_url,
"flaky.zip",
cache_dir,
None,
&fast_retry(2),
&progress,
));
assert!(result.is_err());
let positions = progress.positions.lock().unwrap();
assert_eq!(positions.as_slice(), &[0, 0, 0]);
mock.assert();
}
#[test]
fn test_get_manifest_from_network_and_cache_tolerates_marker_write_failure() {
let mut server = Server::new();
let test_date = "Wed, 21 Feb 2024 15:30:45 GMT";
let mock = server
.mock("GET", "/pkg/")
.with_status(200)
.with_header("Content-Type", "application/json")
.with_header("Last-Modified", test_date)
.with_body(sample_manifest_json())
.create();
let api_url = format!("{}/pkg/", server.url());
let temp_dir = tempdir().unwrap();
let cache_dir = temp_dir.path();
std::fs::create_dir(last_modified_path(cache_dir)).unwrap();
let rt = Runtime::new().unwrap();
let result = rt.block_on(get_manifest_from_network_and_cache(
cache_dir,
&test_http(),
&api_url,
None,
&RetryPolicy::none(),
&NoopProgress,
));
assert!(result.is_ok());
let manifest = result.unwrap();
assert_eq!(manifest.len(), 1);
assert!(api_manifest_path(cache_dir).exists());
assert!(last_modified_path(cache_dir).is_dir());
mock.assert();
}
fn sample_package(name: &str, full_name: &str, owner: &str) -> Package {
Package {
name: Some(name.to_string()),
full_name: Some(full_name.to_string()),
owner: Some(owner.to_string()),
package_url: Some(format!("https://example.com/{}", name)),
date_created: OffsetDateTime::now_utc(),
date_updated: OffsetDateTime::now_utc(),
uuid4: Some("test-uuid".to_string()),
rating_score: Some(5),
is_pinned: Some(false),
is_deprecated: Some(false),
has_nsfw_content: Some(false),
categories: vec!["test".to_string()],
versions: vec![],
}
}
fn sample_manifest_json() -> &'static str {
r#"[
{
"name": "ModA",
"full_name": "Owner-ModA",
"owner": "Owner",
"package_url": "https://example.com/mods/ModA",
"date_created": "2024-01-01T12:00:00Z",
"date_updated": "2024-01-02T12:00:00Z",
"uuid4": "test-uuid",
"rating_score": 5,
"is_pinned": false,
"is_deprecated": false,
"has_nsfw_content": false,
"categories": ["category1"],
"versions": [
{
"name": "ModA",
"full_name": "Owner-ModA",
"description": "Test description",
"icon": "icon.png",
"version_number": "1.0.0",
"dependencies": [],
"download_url": "https://example.com/mods/ModA/download",
"downloads": 100,
"date_created": "2024-01-01T12:00:00Z",
"website_url": "https://example.com",
"is_active": true,
"uuid4": "test-version-uuid",
"file_size": 1024
}
]
}
]"#
}
#[test]
fn create_profile_code_posts_payload_and_returns_key() {
let mut server = Server::new();
let mock = server
.mock("POST", "/api/experimental/legacyprofile/create/")
.match_header("content-type", "application/octet-stream")
.match_body(mockito::Matcher::Exact("#r2modman\nQUJD".to_string()))
.with_status(200)
.with_header("Content-Type", "application/json")
.with_body(r#"{"key":"abc-123"}"#)
.create();
let cache = tempdir().unwrap();
let client = ThunderstoreClient::builder()
.base_url(server.url())
.community("valheim")
.cache_dir(cache.path())
.build()
.unwrap();
let rt = Runtime::new().unwrap();
let key = rt
.block_on(client.create_profile_code(b"#r2modman\nQUJD"))
.unwrap();
assert_eq!(key, "abc-123");
mock.assert();
}
#[test]
fn fetch_profile_code_returns_body_bytes() {
let mut server = Server::new();
let mock = server
.mock("GET", "/api/experimental/legacyprofile/get/abc-123/")
.with_status(200)
.with_body("#r2modman\nQUJD")
.create();
let cache = tempdir().unwrap();
let client = ThunderstoreClient::builder()
.base_url(server.url())
.community("valheim")
.cache_dir(cache.path())
.build()
.unwrap();
let rt = Runtime::new().unwrap();
let body = rt.block_on(client.fetch_profile_code("abc-123")).unwrap();
assert_eq!(body, b"#r2modman\nQUJD");
mock.assert();
}
#[test]
fn fetch_profile_code_rejects_unsafe_code() {
let cache = tempdir().unwrap();
let client = ThunderstoreClient::builder()
.community("valheim")
.cache_dir(cache.path())
.build()
.unwrap();
let rt = Runtime::new().unwrap();
assert!(matches!(
rt.block_on(client.fetch_profile_code("../../evil")),
Err(Error::Config(_))
));
}
}