use std::collections::{BTreeMap, BTreeSet};
use std::error::Error as StdError;
use std::fmt;
use std::fs::{self, File};
use std::io::{self, Read, Write};
use std::path::{Component, Path, PathBuf};
use std::str::FromStr;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use cap_std::ambient_authority;
use cap_std::fs::Dir as CapDir;
use md5::{Digest, Md5};
use reqwest::blocking::{Client, Response};
use reqwest::redirect::Policy;
use serde::{Deserialize, Serialize};
use serde_json::Value;
const POLY_HAVEN_API: &str = "https://api.polyhaven.com";
const POLY_HAVEN_DOWNLOAD_HOST: &str = "dl.polyhaven.org";
const DEFAULT_DISCOVERY_LIMIT: usize = 100;
const DEFAULT_MAX_DOWNLOAD_BYTES: u64 = 1024 * 1024 * 1024;
const DOWNLOAD_PROGRESS_GRANULARITY: u64 = 1024 * 1024;
const MAX_API_RESPONSE_BYTES: u64 = 16 * 1024 * 1024;
const MAX_ERROR_RESPONSE_BYTES: u64 = 8 * 1024;
const MAX_MANIFEST_BYTES: u64 = 4 * 1024 * 1024;
const MAX_BUNDLE_FILES: usize = 128;
const MAX_BUNDLE_INCLUDE_DEPTH: usize = 8;
const MAX_BUNDLE_PATH_BYTES: usize = 512;
const MAX_BUNDLE_PATH_COMPONENTS: usize = 16;
const MANIFEST_NAME: &str = "assetforge-asset.json";
pub const POLY_HAVEN_LICENSE_URL: &str = "https://polyhaven.com/license";
pub const POLY_HAVEN_API_TERMS_URL: &str =
"https://github.com/Poly-Haven/Public-API/blob/master/ToS.md";
pub type Result<T> = std::result::Result<T, AssetForgeError>;
#[derive(Debug, thiserror::Error)]
pub enum AssetForgeError {
#[error("invalid asset target '{0}'")]
InvalidTarget(String),
#[error("invalid asset option '{0}'")]
InvalidOption(String),
#[error("{provider} API returned {status}: {body}")]
ProviderApi {
provider: &'static str,
status: u16,
body: String,
},
#[error("{provider} asset '{asset}' was not found")]
NotFound {
provider: &'static str,
asset: String,
},
#[error("{provider} asset '{asset}' has no {resolution} {format} bundle")]
BundleUnavailable {
provider: &'static str,
asset: String,
format: AssetFormat,
resolution: AssetResolution,
},
#[error("refusing untrusted asset download URL '{0}'")]
UntrustedDownloadUrl(String),
#[error("refusing unsafe asset file path '{0}'")]
UnsafePath(String),
#[error("asset bundle is {size} bytes, exceeding the {limit} byte limit")]
DownloadLimitExceeded {
size: u64,
limit: u64,
},
#[error("{label} is {size} bytes, exceeding the {limit} byte limit")]
ResponseLimitExceeded {
label: String,
size: u64,
limit: u64,
},
#[error("download verification failed for '{path}': {message}")]
VerificationFailed {
path: String,
message: String,
},
#[error("refusing to replace unrecognized output '{}': {reason}", path.display())]
UnrecognizedExistingOutput {
path: PathBuf,
reason: String,
},
#[error("asset '{asset}' belongs to {actual}, not {expected}")]
ProviderMismatch {
asset: String,
expected: AssetProvider,
actual: AssetProvider,
},
#[error("output path '{}' is not a directory", .0.display())]
OutputNotDirectory(PathBuf),
#[error("I/O error: {0}")]
Io(#[from] io::Error),
#[error("request error: {0}")]
Request(String),
#[error("JSON error: {0}")]
Json(#[from] serde_json::Error),
}
impl From<reqwest::Error> for AssetForgeError {
fn from(error: reqwest::Error) -> Self {
Self::Request(error_chain(&error))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum AssetProvider {
#[serde(rename = "polyhaven")]
PolyHaven,
}
impl AssetProvider {
pub const ALL: [Self; 1] = [Self::PolyHaven];
pub fn all() -> &'static [Self] {
&Self::ALL
}
pub fn slug(self) -> &'static str {
match self {
Self::PolyHaven => "polyhaven",
}
}
pub fn label(self) -> &'static str {
match self {
Self::PolyHaven => "Poly Haven",
}
}
pub fn from_slug(value: &str) -> Option<Self> {
match normalized_slug(value).as_str() {
"polyhaven" | "ph" => Some(Self::PolyHaven),
_ => None,
}
}
}
impl fmt::Display for AssetProvider {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.slug())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AssetTarget {
Asset(String),
Search(String),
Category(String),
Top,
Latest,
}
impl AssetTarget {
pub fn is_collection(&self) -> bool {
!matches!(self, Self::Asset(_))
}
}
pub fn parse_asset_target(input: &str) -> Result<AssetTarget> {
parse_asset_target_for_provider(input, AssetProvider::PolyHaven)
}
pub fn parse_asset_target_for_provider(
input: &str,
provider: AssetProvider,
) -> Result<AssetTarget> {
let normalized = input.trim();
if normalized.is_empty() {
return Err(AssetForgeError::InvalidTarget("empty target".to_string()));
}
match normalized.to_ascii_lowercase().as_str() {
"top" | "popular" | "trending" => return Ok(AssetTarget::Top),
"latest" | "newest" | "new" => return Ok(AssetTarget::Latest),
_ => {}
}
for (prefix, constructor) in [
("asset:", AssetTarget::Asset as fn(String) -> AssetTarget),
("search:", AssetTarget::Search),
("category:", AssetTarget::Category),
] {
if normalized
.get(..prefix.len())
.is_some_and(|head| head.eq_ignore_ascii_case(prefix))
{
return non_empty_target(&normalized[prefix.len()..], constructor);
}
}
if normalized.contains("://") {
return parse_provider_url(normalized, provider);
}
if normalized.chars().any(char::is_whitespace) {
return Ok(AssetTarget::Search(normalized.to_string()));
}
Ok(AssetTarget::Asset(normalized.to_string()))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum AssetFormat {
Gltf,
Fbx,
Blend,
Usd,
}
impl AssetFormat {
fn api_key(self) -> &'static str {
match self {
Self::Gltf => "gltf",
Self::Fbx => "fbx",
Self::Blend => "blend",
Self::Usd => "usd",
}
}
}
impl fmt::Display for AssetFormat {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.api_key())
}
}
impl FromStr for AssetFormat {
type Err = AssetForgeError;
fn from_str(value: &str) -> Result<Self> {
match normalized_slug(value).as_str() {
"gltf" => Ok(Self::Gltf),
"fbx" => Ok(Self::Fbx),
"blend" | "blender" => Ok(Self::Blend),
"usd" | "usdc" => Ok(Self::Usd),
_ => Err(AssetForgeError::InvalidOption(value.to_string())),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum AssetResolution {
#[serde(rename = "1k")]
OneK,
#[serde(rename = "2k")]
TwoK,
#[serde(rename = "4k")]
FourK,
#[serde(rename = "8k")]
EightK,
}
impl AssetResolution {
pub fn slug(self) -> &'static str {
match self {
Self::OneK => "1k",
Self::TwoK => "2k",
Self::FourK => "4k",
Self::EightK => "8k",
}
}
}
impl fmt::Display for AssetResolution {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.slug())
}
}
impl FromStr for AssetResolution {
type Err = AssetForgeError;
fn from_str(value: &str) -> Result<Self> {
match normalized_slug(value).as_str() {
"1k" | "1024" => Ok(Self::OneK),
"2k" | "2048" => Ok(Self::TwoK),
"4k" | "4096" => Ok(Self::FourK),
"8k" | "8192" => Ok(Self::EightK),
_ => Err(AssetForgeError::InvalidOption(value.to_string())),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AssetRights {
pub license: String,
pub license_url: String,
pub attribution: String,
pub api_terms_url: String,
}
impl AssetRights {
fn poly_haven() -> Self {
Self {
license: "CC0-1.0".to_string(),
license_url: POLY_HAVEN_LICENSE_URL.to_string(),
attribution: "Poly Haven".to_string(),
api_terms_url: POLY_HAVEN_API_TERMS_URL.to_string(),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AssetSummary {
pub provider: AssetProvider,
pub id: String,
pub name: String,
pub description: String,
pub categories: Vec<String>,
pub tags: Vec<String>,
pub download_count: u64,
pub polycount: u64,
pub date_published: u64,
pub thumbnail_url: String,
pub source_url: String,
pub rights: AssetRights,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AssetFile {
pub path: String,
pub size: u64,
pub url: String,
pub md5: String,
pub role: AssetFileRole,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum AssetFileRole {
Scene,
Dependency,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AssetBundle {
pub provider: AssetProvider,
pub asset_id: String,
pub source_url: String,
pub rights: AssetRights,
pub format: AssetFormat,
pub resolution: AssetResolution,
pub files: Vec<AssetFile>,
pub total_bytes: u64,
}
#[derive(Debug, Clone)]
pub struct ArchiveOptions {
pub output: PathBuf,
pub format: AssetFormat,
pub resolution: AssetResolution,
pub skip_existing: bool,
pub max_download_bytes: u64,
}
impl Default for ArchiveOptions {
fn default() -> Self {
Self {
output: PathBuf::from("assets"),
format: AssetFormat::Gltf,
resolution: AssetResolution::TwoK,
skip_existing: false,
max_download_bytes: DEFAULT_MAX_DOWNLOAD_BYTES,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ArchivedAsset {
pub asset_id: String,
pub output: PathBuf,
pub files_archived: usize,
pub bytes_written: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ArchiveFailure {
pub asset_id: String,
pub message: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ArchiveSummary {
pub selected: usize,
pub archived: usize,
pub skipped: usize,
pub files_archived: usize,
pub bytes_written: u64,
pub assets: Vec<ArchivedAsset>,
pub failed: Vec<ArchiveFailure>,
}
impl ArchiveSummary {
pub fn is_success(&self) -> bool {
self.failed.is_empty()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ArchiveProgressPhase {
Starting,
Downloading,
Completed,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ArchiveProgress {
pub phase: ArchiveProgressPhase,
pub total_assets: usize,
pub completed_assets: usize,
pub succeeded_assets: usize,
pub failed_assets: usize,
pub bytes_written: u64,
pub active_asset: Option<String>,
pub active_file: Option<String>,
pub active_bytes_written: u64,
pub active_total_bytes: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AssetManifest {
pub format_version: String,
pub provider: AssetProvider,
pub asset: AssetSummary,
pub bundle: AssetBundle,
pub bytes_written: u64,
pub complete: bool,
}
#[derive(Clone)]
pub struct AssetForge {
provider: AssetProvider,
client: Client,
discovery_limit: usize,
}
impl AssetForge {
pub fn with_user_agent(provider: AssetProvider, user_agent: &str) -> Result<Self> {
let user_agent = user_agent.trim();
if user_agent.is_empty() {
return Err(AssetForgeError::InvalidOption(
"User-Agent must not be empty".to_string(),
));
}
let client = Client::builder()
.user_agent(user_agent)
.redirect(Policy::none())
.timeout(Duration::from_secs(60))
.build()?;
Ok(Self {
provider,
client,
discovery_limit: DEFAULT_DISCOVERY_LIMIT,
})
}
pub fn with_discovery_limit(mut self, limit: usize) -> Self {
self.discovery_limit = limit.max(1);
self
}
pub fn discover(&self, target: &AssetTarget) -> Result<Vec<AssetSummary>> {
match self.provider {
AssetProvider::PolyHaven => {
let catalog = self.poly_haven_catalog()?;
discover_catalog(catalog, target, self.discovery_limit)
}
}
}
pub fn bundle(
&self,
asset_id: &str,
format: AssetFormat,
resolution: AssetResolution,
) -> Result<AssetBundle> {
match self.provider {
AssetProvider::PolyHaven => {
let url = poly_haven_api_url("files", asset_id)?;
let value: Value = self.get_json(url.as_str())?;
bundle_from_value(asset_id, format, resolution, &value)
}
}
}
pub fn archive_assets(
&self,
assets: &[AssetSummary],
options: &ArchiveOptions,
) -> Result<ArchiveSummary> {
self.archive_assets_inner(assets, options, None)
}
pub fn archive_assets_with_progress<F>(
&self,
assets: &[AssetSummary],
options: &ArchiveOptions,
mut progress: F,
) -> Result<ArchiveSummary>
where
F: FnMut(ArchiveProgress),
{
self.archive_assets_inner(assets, options, Some(&mut progress))
}
fn archive_assets_inner(
&self,
assets: &[AssetSummary],
options: &ArchiveOptions,
mut progress: Option<&mut dyn FnMut(ArchiveProgress)>,
) -> Result<ArchiveSummary> {
prepare_output(&options.output)?;
let mut summary = ArchiveSummary {
selected: assets.len(),
..ArchiveSummary::default()
};
emit_progress(
&mut progress,
progress_snapshot(ArchiveProgressPhase::Starting, assets.len(), &summary),
);
for asset in assets {
let retained_bytes = summary.bytes_written;
let result = (|| -> Result<Option<ArchivedAsset>> {
if asset.provider != self.provider {
return Err(AssetForgeError::ProviderMismatch {
asset: asset.id.clone(),
expected: self.provider,
actual: asset.provider,
});
}
let asset_dir = options.output.join(asset_directory_name(&asset.id)?);
if options.skip_existing && asset_dir.join(MANIFEST_NAME).exists() {
let current_bundle =
self.bundle(&asset.id, options.format, options.resolution)?;
if matching_complete_manifest(
&asset_dir,
self.provider,
&asset.id,
¤t_bundle,
)? {
return Ok(None);
}
}
ensure_replaceable_output(&asset_dir, self.provider, &asset.id)?;
let staging_dir = staging_path(&options.output, &asset.id)?;
fs::create_dir(&staging_dir)?;
let archive_result = (|| -> Result<ArchivedAsset> {
let authoritative_asset = self.poly_haven_asset(&asset.id)?;
let bundle = self.bundle(&asset.id, options.format, options.resolution)?;
if options.max_download_bytes > 0
&& bundle.total_bytes > options.max_download_bytes
{
return Err(AssetForgeError::DownloadLimitExceeded {
size: bundle.total_bytes,
limit: options.max_download_bytes,
});
}
let mut asset_bytes = 0u64;
for file in &bundle.files {
let output = staging_dir.join(path_from_manifest(&file.path)?);
let prior_asset_bytes = asset_bytes;
let active_asset = asset.id.clone();
let active_file = file.path.clone();
let bytes = download_file(&self.client, file, &output, |active_bytes| {
emit_progress(
&mut progress,
ArchiveProgress {
phase: ArchiveProgressPhase::Downloading,
total_assets: assets.len(),
completed_assets: summary.archived
+ summary.skipped
+ summary.failed.len(),
succeeded_assets: summary.archived,
failed_assets: summary.failed.len(),
bytes_written: retained_bytes
.saturating_add(prior_asset_bytes)
.saturating_add(active_bytes),
active_asset: Some(active_asset.clone()),
active_file: Some(active_file.clone()),
active_bytes_written: active_bytes,
active_total_bytes: Some(file.size),
},
);
})?;
asset_bytes = asset_bytes.saturating_add(bytes);
}
let manifest = AssetManifest {
format_version: "1".to_string(),
provider: self.provider,
asset: authoritative_asset,
bundle: bundle.clone(),
bytes_written: asset_bytes,
complete: true,
};
write_manifest(&staging_dir, &manifest)?;
validate_manifest_identity(&manifest, self.provider, &asset.id)?;
verify_manifest_files(&staging_dir, &manifest)?;
Ok(ArchivedAsset {
asset_id: asset.id.clone(),
output: asset_dir.clone(),
files_archived: bundle.files.len(),
bytes_written: asset_bytes,
})
})();
match archive_result {
Ok(archived) => {
if let Err(error) = commit_staged_archive(&staging_dir, &asset_dir) {
let _ = fs::remove_dir_all(&staging_dir);
return Err(error);
}
Ok(Some(archived))
}
Err(error) => {
let _ = fs::remove_dir_all(&staging_dir);
Err(error)
}
}
})();
match result {
Ok(Some(archived)) => {
summary.archived += 1;
summary.files_archived += archived.files_archived;
summary.bytes_written =
summary.bytes_written.saturating_add(archived.bytes_written);
summary.assets.push(archived);
}
Ok(None) => summary.skipped += 1,
Err(error) => {
summary.failed.push(ArchiveFailure {
asset_id: asset.id.clone(),
message: error.to_string(),
});
}
}
emit_progress(
&mut progress,
progress_snapshot(ArchiveProgressPhase::Downloading, assets.len(), &summary),
);
}
emit_progress(
&mut progress,
progress_snapshot(ArchiveProgressPhase::Completed, assets.len(), &summary),
);
Ok(summary)
}
fn poly_haven_catalog(&self) -> Result<BTreeMap<String, PolyHavenAsset>> {
self.get_json(&format!("{POLY_HAVEN_API}/assets?t=models"))
}
fn poly_haven_asset(&self, asset_id: &str) -> Result<AssetSummary> {
let url = poly_haven_api_url("info", asset_id)?;
let asset: PolyHavenAsset = self.get_json(url.as_str())?;
Ok(asset_summary(asset_id, &asset))
}
fn get_json<T>(&self, url: &str) -> Result<T>
where
T: for<'de> Deserialize<'de>,
{
let response = self.client.get(url).send()?;
let status = response.status();
if !status.is_success() {
let body = bounded_error_body(response);
return Err(AssetForgeError::ProviderApi {
provider: self.provider.slug(),
status: status.as_u16(),
body,
});
}
let bytes = read_response_limited(response, MAX_API_RESPONSE_BYTES, "provider response")?;
Ok(serde_json::from_slice(&bytes)?)
}
}
#[derive(Debug, Clone, Deserialize)]
struct PolyHavenAsset {
#[serde(default)]
name: String,
#[serde(default)]
description: String,
#[serde(default)]
categories: Vec<String>,
#[serde(default)]
category: String,
#[serde(default)]
tags: Vec<String>,
#[serde(default)]
download_count: u64,
#[serde(default)]
polycount: u64,
#[serde(default)]
date_published: u64,
#[serde(default)]
thumbnail_url: String,
}
#[derive(Debug, Clone, Deserialize)]
struct PolyHavenFile {
size: u64,
url: String,
md5: String,
#[serde(default)]
include: BTreeMap<String, PolyHavenFile>,
}
fn discover_catalog(
catalog: BTreeMap<String, PolyHavenAsset>,
target: &AssetTarget,
limit: usize,
) -> Result<Vec<AssetSummary>> {
if let AssetTarget::Asset(id) = target {
let Some(asset) = catalog.get(id) else {
return Err(AssetForgeError::NotFound {
provider: AssetProvider::PolyHaven.slug(),
asset: id.clone(),
});
};
return Ok(vec![asset_summary(id, asset)]);
}
let needle = match target {
AssetTarget::Search(value) | AssetTarget::Category(value) => {
Some(value.trim().to_ascii_lowercase())
}
_ => None,
};
let search_terms = needle
.as_deref()
.unwrap_or_default()
.split_whitespace()
.collect::<Vec<_>>();
let mut assets = catalog
.iter()
.filter(|(id, asset)| match target {
AssetTarget::Search(_) => {
let haystack = format!(
"{} {} {} {} {}",
id,
asset.name,
asset.description,
asset.tags.join(" "),
asset.categories.join(" ")
)
.to_ascii_lowercase();
!search_terms.is_empty() && search_terms.iter().all(|term| haystack.contains(term))
}
AssetTarget::Category(_) => {
let needle = needle.as_deref().unwrap_or_default();
asset.category.eq_ignore_ascii_case(needle)
|| asset
.categories
.iter()
.any(|category| category.eq_ignore_ascii_case(needle))
}
AssetTarget::Top | AssetTarget::Latest => true,
AssetTarget::Asset(_) => false,
})
.map(|(id, asset)| asset_summary(id, asset))
.collect::<Vec<_>>();
match target {
AssetTarget::Latest => assets.sort_by(|left, right| {
right
.date_published
.cmp(&left.date_published)
.then_with(|| left.id.cmp(&right.id))
}),
_ => assets.sort_by(|left, right| {
right
.download_count
.cmp(&left.download_count)
.then_with(|| left.id.cmp(&right.id))
}),
}
assets.truncate(limit);
Ok(assets)
}
fn asset_summary(id: &str, asset: &PolyHavenAsset) -> AssetSummary {
AssetSummary {
provider: AssetProvider::PolyHaven,
id: id.to_string(),
name: asset.name.clone(),
description: asset.description.clone(),
categories: asset.categories.clone(),
tags: asset.tags.clone(),
download_count: asset.download_count,
polycount: asset.polycount,
date_published: asset.date_published,
thumbnail_url: asset.thumbnail_url.clone(),
source_url: format!("https://polyhaven.com/a/{id}"),
rights: AssetRights::poly_haven(),
}
}
fn bundle_from_value(
asset_id: &str,
format: AssetFormat,
resolution: AssetResolution,
value: &Value,
) -> Result<AssetBundle> {
let entry = value
.get(format.api_key())
.and_then(|formats| formats.get(resolution.slug()))
.and_then(|resolution| resolution.get(format.api_key()))
.ok_or_else(|| AssetForgeError::BundleUnavailable {
provider: AssetProvider::PolyHaven.slug(),
asset: asset_id.to_string(),
format,
resolution,
})?;
let scene: PolyHavenFile = serde_json::from_value(entry.clone())?;
let scene_url = trusted_download_url(&scene.url)?;
let scene_name = scene_url
.path_segments()
.and_then(|mut segments| segments.next_back())
.filter(|name| !name.is_empty())
.ok_or_else(|| AssetForgeError::UnsafePath(scene.url.clone()))?;
let scene_path = safe_relative_path(scene_name)?;
let mut files = Vec::new();
let mut seen_paths = BTreeMap::new();
push_bundle_file(
&mut files,
&mut seen_paths,
scene_path,
&scene,
AssetFileRole::Scene,
0,
)?;
let total_bytes = files
.iter()
.fold(0u64, |total, file| total.saturating_add(file.size));
Ok(AssetBundle {
provider: AssetProvider::PolyHaven,
asset_id: asset_id.to_string(),
source_url: format!("https://polyhaven.com/a/{asset_id}"),
rights: AssetRights::poly_haven(),
format,
resolution,
files,
total_bytes,
})
}
fn push_bundle_file(
files: &mut Vec<AssetFile>,
seen_paths: &mut BTreeMap<String, (String, String)>,
path: PathBuf,
file: &PolyHavenFile,
role: AssetFileRole,
depth: usize,
) -> Result<()> {
if depth > MAX_BUNDLE_INCLUDE_DEPTH {
return Err(AssetForgeError::ResponseLimitExceeded {
label: "bundle include depth".to_string(),
size: depth as u64,
limit: MAX_BUNDLE_INCLUDE_DEPTH as u64,
});
}
trusted_download_url(&file.url)?;
let path = manifest_path(&path)?;
validate_bundle_path(&path)?;
if file.md5.len() != 32 || !file.md5.bytes().all(|byte| byte.is_ascii_hexdigit()) {
return Err(AssetForgeError::VerificationFailed {
path,
message: "provider MD5 is not 32 hexadecimal characters".to_string(),
});
}
let path_key = path.to_lowercase();
match seen_paths.get(&path_key) {
Some((existing_path, existing_url))
if existing_path == &path && existing_url == &file.url =>
{
return Ok(());
}
Some(_) => return Err(AssetForgeError::UnsafePath(path)),
None => {
if files.len() >= MAX_BUNDLE_FILES {
return Err(AssetForgeError::ResponseLimitExceeded {
label: "bundle file count".to_string(),
size: files.len().saturating_add(1) as u64,
limit: MAX_BUNDLE_FILES as u64,
});
}
seen_paths.insert(path_key, (path.clone(), file.url.clone()));
}
}
{
files.push(AssetFile {
path,
size: file.size,
url: file.url.clone(),
md5: file.md5.to_ascii_lowercase(),
role,
});
}
for (include_path, dependency) in &file.include {
push_bundle_file(
files,
seen_paths,
safe_relative_path(include_path)?,
dependency,
AssetFileRole::Dependency,
depth.saturating_add(1),
)?;
}
Ok(())
}
fn download_file<F>(
client: &Client,
asset_file: &AssetFile,
output: &Path,
mut progress: F,
) -> Result<u64>
where
F: FnMut(u64),
{
trusted_download_url(&asset_file.url)?;
if let Some(parent) = output.parent() {
fs::create_dir_all(parent)?;
}
let mut response = client.get(&asset_file.url).send()?;
trusted_download_url(response.url().as_str())?;
let status = response.status();
if !status.is_success() {
let body = bounded_error_body(response);
return Err(AssetForgeError::ProviderApi {
provider: AssetProvider::PolyHaven.slug(),
status: status.as_u16(),
body,
});
}
if let Some(length) = response.content_length() {
if length != asset_file.size {
return Err(verification_error(
asset_file,
format!(
"provider expected {} bytes but server advertised {length}",
asset_file.size
),
));
}
}
let partial = partial_path(output);
let mut file = File::create(&partial)?;
let mut hasher = Md5::new();
let mut bytes_written = 0u64;
let mut last_emit = 0u64;
let mut buffer = [0u8; 64 * 1024];
loop {
let read = match response.read(&mut buffer) {
Ok(read) => read,
Err(error) => {
drop(file);
let _ = fs::remove_file(&partial);
return Err(AssetForgeError::Request(error_chain(&error)));
}
};
if read == 0 {
break;
}
if let Err(error) = file.write_all(&buffer[..read]) {
drop(file);
let _ = fs::remove_file(&partial);
return Err(AssetForgeError::Io(error));
}
hasher.update(&buffer[..read]);
bytes_written = bytes_written.saturating_add(read as u64);
if bytes_written > asset_file.size {
drop(file);
let _ = fs::remove_file(&partial);
return Err(verification_error(
asset_file,
format!("received more than the expected {} bytes", asset_file.size),
));
}
if bytes_written.saturating_sub(last_emit) >= DOWNLOAD_PROGRESS_GRANULARITY {
progress(bytes_written);
last_emit = bytes_written;
}
}
if bytes_written != asset_file.size {
drop(file);
let _ = fs::remove_file(&partial);
return Err(verification_error(
asset_file,
format!(
"expected {} bytes but received {bytes_written}",
asset_file.size
),
));
}
let checksum = format!("{:x}", hasher.finalize());
if !checksum.eq_ignore_ascii_case(&asset_file.md5) {
drop(file);
let _ = fs::remove_file(&partial);
return Err(verification_error(
asset_file,
format!("expected MD5 {} but received {checksum}", asset_file.md5),
));
}
if let Err(error) = file.flush().and_then(|()| file.sync_all()) {
drop(file);
let _ = fs::remove_file(&partial);
return Err(AssetForgeError::Io(error));
}
drop(file);
if last_emit != bytes_written {
progress(bytes_written);
}
if let Err(error) = fs::rename(&partial, output) {
let _ = fs::remove_file(&partial);
return Err(AssetForgeError::Io(error));
}
Ok(bytes_written)
}
fn verification_error(file: &AssetFile, message: String) -> AssetForgeError {
AssetForgeError::VerificationFailed {
path: file.path.clone(),
message,
}
}
fn poly_haven_api_url(endpoint: &str, asset_id: &str) -> Result<reqwest::Url> {
let mut url = reqwest::Url::parse(POLY_HAVEN_API)
.map_err(|_| AssetForgeError::InvalidTarget(asset_id.to_string()))?;
url.path_segments_mut()
.map_err(|_| AssetForgeError::InvalidTarget(asset_id.to_string()))?
.push(endpoint)
.push(asset_id);
Ok(url)
}
fn read_response_limited(mut response: Response, limit: u64, label: &str) -> Result<Vec<u8>> {
if let Some(length) = response.content_length() {
if length > limit {
return Err(AssetForgeError::ResponseLimitExceeded {
label: label.to_string(),
size: length,
limit,
});
}
}
let mut bytes = Vec::new();
response
.by_ref()
.take(limit.saturating_add(1))
.read_to_end(&mut bytes)?;
if bytes.len() as u64 > limit {
return Err(AssetForgeError::ResponseLimitExceeded {
label: label.to_string(),
size: bytes.len() as u64,
limit,
});
}
Ok(bytes)
}
fn bounded_error_body(response: Response) -> String {
match read_response_limited(
response,
MAX_ERROR_RESPONSE_BYTES,
"provider error response",
) {
Ok(bytes) => String::from_utf8_lossy(&bytes).into_owned(),
Err(AssetForgeError::ResponseLimitExceeded { .. }) => {
"<error response exceeded safety limit>".to_string()
}
Err(_) => "<unable to read body>".to_string(),
}
}
fn trusted_download_url(url: &str) -> Result<reqwest::Url> {
let parsed = reqwest::Url::parse(url)
.map_err(|_| AssetForgeError::UntrustedDownloadUrl(url.to_string()))?;
if parsed.scheme() != "https"
|| parsed.host_str() != Some(POLY_HAVEN_DOWNLOAD_HOST)
|| parsed.port().is_some_and(|port| port != 443)
|| !parsed.username().is_empty()
|| parsed.password().is_some()
{
return Err(AssetForgeError::UntrustedDownloadUrl(url.to_string()));
}
Ok(parsed)
}
fn parse_provider_url(input: &str, provider: AssetProvider) -> Result<AssetTarget> {
let url = reqwest::Url::parse(input)
.map_err(|_| AssetForgeError::InvalidTarget(input.to_string()))?;
match provider {
AssetProvider::PolyHaven => {
let host = url.host_str().unwrap_or_default();
if host != "polyhaven.com" && host != "www.polyhaven.com" {
return Err(AssetForgeError::InvalidTarget(input.to_string()));
}
let segments = url
.path_segments()
.map(|segments| segments.filter(|part| !part.is_empty()).collect::<Vec<_>>())
.unwrap_or_default();
if segments.len() != 2 || segments[0] != "a" {
return Err(AssetForgeError::InvalidTarget(input.to_string()));
}
non_empty_target(segments[1], AssetTarget::Asset)
}
}
}
fn non_empty_target(value: &str, constructor: fn(String) -> AssetTarget) -> Result<AssetTarget> {
let value = value.trim();
if value.is_empty() {
Err(AssetForgeError::InvalidTarget(value.to_string()))
} else {
Ok(constructor(value.to_string()))
}
}
fn prepare_output(output: &Path) -> Result<()> {
if output.exists() && !output.is_dir() {
return Err(AssetForgeError::OutputNotDirectory(output.to_path_buf()));
}
fs::create_dir_all(output)?;
Ok(())
}
fn asset_directory_name(value: &str) -> Result<String> {
let value = value.trim();
if value.is_empty()
|| value == "."
|| value == ".."
|| !value.chars().all(|character| {
character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.')
})
{
return Err(AssetForgeError::UnsafePath(value.to_string()));
}
Ok(value.to_string())
}
fn safe_relative_path(value: &str) -> Result<PathBuf> {
let path = Path::new(value);
if path.is_absolute() {
return Err(AssetForgeError::UnsafePath(value.to_string()));
}
let mut safe = PathBuf::new();
for component in path.components() {
match component {
Component::Normal(part) if !part.is_empty() => safe.push(part),
_ => return Err(AssetForgeError::UnsafePath(value.to_string())),
}
}
if safe.as_os_str().is_empty() {
return Err(AssetForgeError::UnsafePath(value.to_string()));
}
Ok(safe)
}
fn manifest_path(path: &Path) -> Result<String> {
let path = safe_relative_path(&path.to_string_lossy())?;
Ok(path
.components()
.map(|component| component.as_os_str().to_string_lossy())
.collect::<Vec<_>>()
.join("/"))
}
fn validate_bundle_path(path: &str) -> Result<()> {
let path = manifest_path(&safe_relative_path(path)?)?;
let component_count = Path::new(&path).components().count();
if path.len() > MAX_BUNDLE_PATH_BYTES || component_count > MAX_BUNDLE_PATH_COMPONENTS {
return Err(AssetForgeError::ResponseLimitExceeded {
label: "bundle path".to_string(),
size: path.len().max(component_count) as u64,
limit: MAX_BUNDLE_PATH_BYTES.max(MAX_BUNDLE_PATH_COMPONENTS) as u64,
});
}
let file_name = Path::new(&path)
.file_name()
.and_then(|name| name.to_str())
.unwrap_or_default();
if path.eq_ignore_ascii_case(MANIFEST_NAME)
|| path.eq_ignore_ascii_case(&format!("{MANIFEST_NAME}.tmp"))
|| file_name.to_ascii_lowercase().ends_with(".assetforge-part")
{
return Err(AssetForgeError::UnsafePath(path));
}
Ok(())
}
fn path_from_manifest(path: &str) -> Result<PathBuf> {
safe_relative_path(path)
}
fn partial_path(path: &Path) -> PathBuf {
let mut partial = path.to_path_buf();
let extension = path
.extension()
.and_then(|value| value.to_str())
.map_or_else(
|| "assetforge-part".to_string(),
|value| format!("{value}.assetforge-part"),
);
partial.set_extension(extension);
partial
}
fn write_manifest(asset_dir: &Path, manifest: &AssetManifest) -> Result<()> {
let final_path = asset_dir.join(MANIFEST_NAME);
let temporary_path = asset_dir.join(format!("{MANIFEST_NAME}.tmp"));
let payload = serde_json::to_vec_pretty(manifest)?;
if payload.len() as u64 > MAX_MANIFEST_BYTES {
return Err(AssetForgeError::ResponseLimitExceeded {
label: MANIFEST_NAME.to_string(),
size: payload.len() as u64,
limit: MAX_MANIFEST_BYTES,
});
}
let mut file = File::create(&temporary_path)?;
file.write_all(&payload)?;
file.flush()?;
file.sync_all()?;
drop(file);
fs::rename(temporary_path, final_path)?;
Ok(())
}
fn matching_complete_manifest(
asset_dir: &Path,
provider: AssetProvider,
asset_id: &str,
current_bundle: &AssetBundle,
) -> Result<bool> {
if !asset_dir.exists() || !asset_dir.join(MANIFEST_NAME).exists() {
return Ok(false);
}
let directory = open_archive_directory(asset_dir)?;
let manifest = read_manifest_bounded(&directory)?;
validate_manifest_identity(&manifest, provider, asset_id)?;
if manifest.bundle != *current_bundle {
return Ok(false);
}
verify_manifest_files_in_directory(&directory, &manifest)?;
Ok(true)
}
fn read_manifest_bounded(directory: &CapDir) -> Result<AssetManifest> {
let metadata = directory.symlink_metadata(MANIFEST_NAME)?;
if !metadata.file_type().is_file() || metadata.file_type().is_symlink() {
return Err(AssetForgeError::UnsafePath(MANIFEST_NAME.to_string()));
}
if metadata.len() > MAX_MANIFEST_BYTES {
return Err(AssetForgeError::ResponseLimitExceeded {
label: MANIFEST_NAME.to_string(),
size: metadata.len(),
limit: MAX_MANIFEST_BYTES,
});
}
let mut contents = Vec::with_capacity(metadata.len() as usize);
directory
.open(MANIFEST_NAME)?
.take(MAX_MANIFEST_BYTES.saturating_add(1))
.read_to_end(&mut contents)?;
Ok(serde_json::from_slice(&contents)?)
}
fn validate_manifest_identity(
manifest: &AssetManifest,
provider: AssetProvider,
asset_id: &str,
) -> Result<()> {
let expected_rights = AssetRights::poly_haven();
let valid = manifest.format_version == "1"
&& manifest.complete
&& manifest.provider == provider
&& manifest.asset.provider == provider
&& manifest.bundle.provider == provider
&& manifest.asset.id == asset_id
&& manifest.bundle.asset_id == asset_id
&& manifest.asset.source_url == format!("https://polyhaven.com/a/{asset_id}")
&& manifest.bundle.source_url == manifest.asset.source_url
&& manifest.asset.rights == expected_rights
&& manifest.bundle.rights == expected_rights;
if !valid {
return Err(AssetForgeError::VerificationFailed {
path: MANIFEST_NAME.to_string(),
message: "manifest identity or rights metadata is inconsistent".to_string(),
});
}
Ok(())
}
fn verify_manifest_files(asset_dir: &Path, manifest: &AssetManifest) -> Result<()> {
let directory = open_archive_directory(asset_dir)?;
verify_manifest_files_in_directory(&directory, manifest)
}
fn verify_manifest_files_in_directory(directory: &CapDir, manifest: &AssetManifest) -> Result<()> {
let mut seen_paths = BTreeSet::new();
let mut total_bytes = 0u64;
if manifest.bundle.files.is_empty() {
return Err(AssetForgeError::VerificationFailed {
path: MANIFEST_NAME.to_string(),
message: "bundle has no files".to_string(),
});
}
for file in &manifest.bundle.files {
validate_bundle_path(&file.path)?;
trusted_download_url(&file.url)?;
if !seen_paths.insert(file.path.clone()) {
return Err(verification_error(
file,
"duplicate manifest path".to_string(),
));
}
let path = path_from_manifest(&file.path)?;
let (actual_size, checksum) = inspect_regular_file(directory, &path, file)?;
if actual_size != file.size {
return Err(verification_error(
file,
format!("expected {} bytes but found {actual_size}", file.size),
));
}
if !checksum.eq_ignore_ascii_case(&file.md5) {
return Err(verification_error(
file,
format!("expected MD5 {} but found {checksum}", file.md5),
));
}
total_bytes = total_bytes.saturating_add(actual_size);
}
if total_bytes != manifest.bundle.total_bytes || total_bytes != manifest.bytes_written {
return Err(AssetForgeError::VerificationFailed {
path: MANIFEST_NAME.to_string(),
message: "manifest byte totals do not match archived files".to_string(),
});
}
Ok(())
}
fn inspect_regular_file(
directory: &CapDir,
relative: &Path,
file: &AssetFile,
) -> Result<(u64, String)> {
let components = relative.components().collect::<Vec<_>>();
let mut current = PathBuf::new();
for (index, component) in components.iter().enumerate() {
let Component::Normal(part) = component else {
return Err(AssetForgeError::UnsafePath(relative.display().to_string()));
};
current.push(part);
let metadata = directory.symlink_metadata(¤t)?;
if metadata.file_type().is_symlink() {
return Err(verification_error(
file,
format!("path component '{}' is a symlink", current.display()),
));
}
let is_last = index + 1 == components.len();
if (!is_last && !metadata.is_dir()) || (is_last && !metadata.is_file()) {
return Err(verification_error(
file,
format!(
"path component '{}' has the wrong file type",
current.display()
),
));
}
if is_last {
break;
}
}
let mut opened = directory.open(relative)?;
let metadata = opened.metadata()?;
if !metadata.is_file() {
return Err(verification_error(
file,
"archived path is not a regular file".to_string(),
));
}
let mut hasher = Md5::new();
let mut buffer = [0u8; 64 * 1024];
loop {
let read = opened.read(&mut buffer)?;
if read == 0 {
break;
}
hasher.update(&buffer[..read]);
}
Ok((metadata.len(), format!("{:x}", hasher.finalize())))
}
fn open_archive_directory(path: &Path) -> Result<CapDir> {
let parent_path = path.parent().unwrap_or_else(|| Path::new("."));
let name = path
.file_name()
.ok_or_else(|| AssetForgeError::UnsafePath(path.display().to_string()))?;
let parent = CapDir::open_ambient_dir(parent_path, ambient_authority())?;
let directory = parent.open_dir(name)?;
if !directory.dir_metadata()?.is_dir() {
return Err(AssetForgeError::UnsafePath(path.display().to_string()));
}
Ok(directory)
}
fn ensure_replaceable_output(
asset_dir: &Path,
provider: AssetProvider,
asset_id: &str,
) -> Result<()> {
if !asset_dir.exists() {
return Ok(());
}
let directory = open_archive_directory(asset_dir).map_err(|error| {
AssetForgeError::UnrecognizedExistingOutput {
path: asset_dir.to_path_buf(),
reason: error.to_string(),
}
})?;
let manifest = read_manifest_bounded(&directory).map_err(|error| {
AssetForgeError::UnrecognizedExistingOutput {
path: asset_dir.to_path_buf(),
reason: error.to_string(),
}
})?;
validate_manifest_identity(&manifest, provider, asset_id).map_err(|error| {
AssetForgeError::UnrecognizedExistingOutput {
path: asset_dir.to_path_buf(),
reason: error.to_string(),
}
})
}
fn staging_path(output: &Path, asset_id: &str) -> Result<PathBuf> {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|error| AssetForgeError::InvalidOption(error.to_string()))?
.as_nanos();
Ok(output.join(format!(
".assetforge-{}-{}-{nonce}.staging",
asset_directory_name(asset_id)?,
std::process::id()
)))
}
fn commit_staged_archive(staging: &Path, destination: &Path) -> Result<()> {
if !destination.exists() {
fs::rename(staging, destination)?;
return Ok(());
}
let file_name = destination
.file_name()
.and_then(|name| name.to_str())
.ok_or_else(|| AssetForgeError::UnsafePath(destination.display().to_string()))?;
let backup = destination.with_file_name(format!(
".assetforge-{file_name}-{}-backup",
std::process::id()
));
if backup.exists() {
return Err(AssetForgeError::UnrecognizedExistingOutput {
path: backup,
reason: "stale replacement backup exists".to_string(),
});
}
fs::rename(destination, &backup)?;
if let Err(error) = fs::rename(staging, destination) {
let _ = fs::rename(&backup, destination);
return Err(AssetForgeError::Io(error));
}
let _ = fs::remove_dir_all(backup);
Ok(())
}
fn progress_snapshot(
phase: ArchiveProgressPhase,
total_assets: usize,
summary: &ArchiveSummary,
) -> ArchiveProgress {
ArchiveProgress {
phase,
total_assets,
completed_assets: summary.archived + summary.skipped + summary.failed.len(),
succeeded_assets: summary.archived,
failed_assets: summary.failed.len(),
bytes_written: summary.bytes_written,
active_asset: None,
active_file: None,
active_bytes_written: 0,
active_total_bytes: None,
}
}
fn emit_progress(
progress: &mut Option<&mut dyn FnMut(ArchiveProgress)>,
snapshot: ArchiveProgress,
) {
if let Some(progress) = progress.as_deref_mut() {
progress(snapshot);
}
}
fn normalized_slug(value: &str) -> String {
value
.trim()
.chars()
.filter(|character| !matches!(character, '-' | '_' | ' ' | '\t' | '\n' | '\r'))
.flat_map(char::to_lowercase)
.collect()
}
fn error_chain(error: &(dyn StdError + 'static)) -> String {
let mut message = error.to_string();
let mut source = error.source();
while let Some(error) = source {
message.push_str("; caused by: ");
message.push_str(&error.to_string());
source = error.source();
}
message
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
fn catalog_fixture() -> BTreeMap<String, PolyHavenAsset> {
serde_json::from_str(
r#"{
"Shelf_01": {
"name": "Shelf 01",
"description": "Weathered wooden shelving",
"categories": ["furniture", "shelves"],
"category": "Furniture/Storage Furniture/Shelving & Bookcases",
"tags": ["wood", "storage"],
"download_count": 100,
"polycount": 182,
"date_published": 10,
"thumbnail_url": "https://cdn.polyhaven.com/shelf.png"
},
"new_chair": {
"name": "New Chair",
"description": "Modern seating",
"categories": ["furniture", "seating"],
"category": "Furniture/Seating/Chairs",
"tags": ["chair"],
"download_count": 20,
"polycount": 500,
"date_published": 20,
"thumbnail_url": "https://cdn.polyhaven.com/chair.png"
}
}"#,
)
.unwrap()
}
#[test]
fn parses_poly_haven_targets() {
assert_eq!(
parse_asset_target("https://polyhaven.com/a/wooden_stool_02").unwrap(),
AssetTarget::Asset("wooden_stool_02".to_string())
);
assert_eq!(
parse_asset_target("search:wooden chair").unwrap(),
AssetTarget::Search("wooden chair".to_string())
);
assert_eq!(
parse_asset_target("category:furniture").unwrap(),
AssetTarget::Category("furniture".to_string())
);
assert_eq!(parse_asset_target("top").unwrap(), AssetTarget::Top);
assert!(parse_asset_target("https://example.com/a/chair").is_err());
assert!(parse_asset_target("https://polyhaven.com/models/chair").is_err());
}
#[test]
fn discovers_and_sorts_catalog_assets() {
let top = discover_catalog(catalog_fixture(), &AssetTarget::Top, 10).unwrap();
assert_eq!(top[0].id, "Shelf_01");
assert_eq!(top[0].provider, AssetProvider::PolyHaven);
assert_eq!(top[0].rights.license, "CC0-1.0");
let latest = discover_catalog(catalog_fixture(), &AssetTarget::Latest, 10).unwrap();
assert_eq!(latest[0].id, "new_chair");
let search = discover_catalog(
catalog_fixture(),
&AssetTarget::Search("storage".to_string()),
10,
)
.unwrap();
assert_eq!(search.len(), 1);
assert_eq!(search[0].id, "Shelf_01");
let category = discover_catalog(
catalog_fixture(),
&AssetTarget::Category("seating".to_string()),
10,
)
.unwrap();
assert_eq!(category[0].id, "new_chair");
let category_path = discover_catalog(
catalog_fixture(),
&AssetTarget::Category("Furniture/Seating/Chairs".to_string()),
10,
)
.unwrap();
assert_eq!(category_path[0].id, "new_chair");
}
#[test]
fn resolves_scene_bundle_with_dependencies() {
let value: Value = serde_json::from_str(
r#"{
"gltf": {
"1k": {
"gltf": {
"size": 4,
"url": "https://dl.polyhaven.org/file/chair.gltf",
"md5": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"include": {
"chair.bin": {
"size": 8,
"url": "https://dl.polyhaven.org/file/chair.bin",
"md5": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
},
"textures/chair_diff.jpg": {
"size": 16,
"url": "https://dl.polyhaven.org/file/chair_diff.jpg",
"md5": "cccccccccccccccccccccccccccccccc"
}
}
}
}
}
}"#,
)
.unwrap();
let bundle =
bundle_from_value("chair", AssetFormat::Gltf, AssetResolution::OneK, &value).unwrap();
assert_eq!(bundle.files.len(), 3);
assert_eq!(bundle.total_bytes, 28);
assert_eq!(bundle.files[0].path, "chair.gltf");
assert_eq!(bundle.files[0].role, AssetFileRole::Scene);
assert!(
bundle
.files
.iter()
.any(|file| file.path == "textures/chair_diff.jpg")
);
}
#[test]
fn rejects_untrusted_downloads_and_unsafe_paths() {
assert!(trusted_download_url("http://dl.polyhaven.org/file/a").is_err());
assert!(trusted_download_url("https://dl.polyhaven.org.evil.test/file/a").is_err());
assert!(safe_relative_path("../secret").is_err());
assert!(safe_relative_path("/absolute").is_err());
assert_eq!(
safe_relative_path("textures/chair.jpg").unwrap(),
PathBuf::from("textures/chair.jpg")
);
}
#[test]
fn preserves_required_aliases_and_rejects_control_paths() {
let aliases: Value = serde_json::from_str(
r#"{
"gltf": {"1k": {"gltf": {
"size": 4,
"url": "https://dl.polyhaven.org/file/chair.gltf",
"md5": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"include": {
"first.bin": {
"size": 8,
"url": "https://dl.polyhaven.org/file/shared.bin",
"md5": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
},
"second.bin": {
"size": 8,
"url": "https://dl.polyhaven.org/file/shared.bin",
"md5": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
}
}
}}}
}"#,
)
.unwrap();
let bundle =
bundle_from_value("chair", AssetFormat::Gltf, AssetResolution::OneK, &aliases).unwrap();
assert_eq!(bundle.files.len(), 3);
assert_eq!(bundle.total_bytes, 20);
let reserved: Value = serde_json::from_str(
r#"{
"gltf": {"1k": {"gltf": {
"size": 4,
"url": "https://dl.polyhaven.org/file/chair.gltf",
"md5": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"include": {
"assetforge-asset.json": {
"size": 8,
"url": "https://dl.polyhaven.org/file/manifest.json",
"md5": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
}
}
}}}
}"#,
)
.unwrap();
assert!(matches!(
bundle_from_value("chair", AssetFormat::Gltf, AssetResolution::OneK, &reserved),
Err(AssetForgeError::UnsafePath(_))
));
}
#[test]
fn parses_format_and_resolution_aliases() {
assert_eq!("gltf".parse::<AssetFormat>().unwrap(), AssetFormat::Gltf);
assert_eq!(
"2048".parse::<AssetResolution>().unwrap(),
AssetResolution::TwoK
);
assert!("obj".parse::<AssetFormat>().is_err());
assert!("16k".parse::<AssetResolution>().is_err());
}
#[test]
fn serializes_stable_provider_and_resolution_slugs() {
assert_eq!(
serde_json::to_string(&AssetProvider::PolyHaven).unwrap(),
"\"polyhaven\""
);
assert_eq!(
serde_json::to_string(&AssetResolution::TwoK).unwrap(),
"\"2k\""
);
}
#[test]
fn skip_existing_verifies_every_archived_file() {
let temp = tempdir().unwrap();
let asset_dir = temp.path().join("chair");
fs::create_dir(&asset_dir).unwrap();
let bytes = b"gltf";
let checksum = format!("{:x}", Md5::digest(bytes));
fs::write(asset_dir.join("chair.gltf"), bytes).unwrap();
let rights = AssetRights::poly_haven();
let asset = AssetSummary {
provider: AssetProvider::PolyHaven,
id: "chair".to_string(),
name: "Chair".to_string(),
description: String::new(),
categories: vec!["furniture".to_string()],
tags: vec!["chair".to_string()],
download_count: 1,
polycount: 1,
date_published: 1,
thumbnail_url: String::new(),
source_url: "https://polyhaven.com/a/chair".to_string(),
rights: rights.clone(),
};
let file = AssetFile {
path: "chair.gltf".to_string(),
size: bytes.len() as u64,
url: "https://dl.polyhaven.org/file/chair.gltf".to_string(),
md5: checksum,
role: AssetFileRole::Scene,
};
let bundle = AssetBundle {
provider: AssetProvider::PolyHaven,
asset_id: "chair".to_string(),
source_url: asset.source_url.clone(),
rights,
format: AssetFormat::Gltf,
resolution: AssetResolution::OneK,
files: vec![file],
total_bytes: bytes.len() as u64,
};
write_manifest(
&asset_dir,
&AssetManifest {
format_version: "1".to_string(),
provider: AssetProvider::PolyHaven,
asset,
bundle: bundle.clone(),
bytes_written: bytes.len() as u64,
complete: true,
},
)
.unwrap();
assert!(
matching_complete_manifest(&asset_dir, AssetProvider::PolyHaven, "chair", &bundle)
.unwrap()
);
fs::write(asset_dir.join("chair.gltf"), b"evil").unwrap();
assert!(
matching_complete_manifest(&asset_dir, AssetProvider::PolyHaven, "chair", &bundle)
.is_err()
);
}
#[test]
fn staged_commit_replaces_destination_without_partial_visibility() {
let temp = tempdir().unwrap();
let destination = temp.path().join("chair");
let staging = temp.path().join("staging");
fs::create_dir(&destination).unwrap();
fs::create_dir(&staging).unwrap();
fs::write(destination.join("old"), b"old").unwrap();
fs::write(staging.join("new"), b"new").unwrap();
commit_staged_archive(&staging, &destination).unwrap();
assert!(!staging.exists());
assert!(!destination.join("old").exists());
assert_eq!(fs::read(destination.join("new")).unwrap(), b"new");
}
#[test]
#[ignore = "requires Poly Haven network access and downloads a 1K glTF bundle"]
fn live_poly_haven_archive_and_verified_skip() {
let forge = AssetForge::with_user_agent(
AssetProvider::PolyHaven,
"assetforge-live-test/0.1 (Tknott95/AssetForge)",
)
.unwrap();
let assets = forge
.discover(&AssetTarget::Asset("Shelf_01".to_string()))
.unwrap();
assert_eq!(assets.len(), 1);
assert_eq!(assets[0].rights.attribution, "Poly Haven");
let temp = tempdir().unwrap();
let options = ArchiveOptions {
output: temp.path().to_path_buf(),
format: AssetFormat::Gltf,
resolution: AssetResolution::OneK,
skip_existing: false,
max_download_bytes: 256 * 1024 * 1024,
};
let summary = forge.archive_assets(&assets, &options).unwrap();
assert!(summary.is_success(), "failures: {:?}", summary.failed);
assert_eq!(summary.archived, 1);
let manifest = read_manifest_bounded(
&open_archive_directory(&temp.path().join("Shelf_01")).unwrap(),
)
.unwrap();
assert!(manifest.complete);
assert_eq!(manifest.asset.rights.license, "CC0-1.0");
let skipped = forge
.archive_assets(
&assets,
&ArchiveOptions {
skip_existing: true,
..options
},
)
.unwrap();
assert_eq!(skipped.skipped, 1);
assert!(skipped.is_success());
}
}