use std::io;
use std::path::{Path, PathBuf};
use crate::config::RomsLayoutConfig;
use crate::config::{resolved_save_dir, Config, SaveSyncConfig};
use crate::core::utils;
use crate::error::DownloadError;
use crate::types::Rom;
use std::fs::File;
use zip::ZipArchive;
pub fn resolve_download_directory(
configured_download_dir: Option<&str>,
) -> Result<PathBuf, DownloadError> {
let env_override = std::env::var("ROMM_ROMS_DIR")
.ok()
.or_else(|| std::env::var("ROMM_DOWNLOAD_DIR").ok());
resolve_download_directory_from_inputs(configured_download_dir, env_override.as_deref())
}
pub fn validate_configured_download_directory(
configured_download_dir: &str,
) -> Result<PathBuf, DownloadError> {
resolve_download_directory_from_inputs(Some(configured_download_dir), None)
}
pub fn download_directory() -> PathBuf {
std::env::var("ROMM_ROMS_DIR")
.or_else(|_| std::env::var("ROMM_DOWNLOAD_DIR"))
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("./downloads"))
}
pub(crate) fn resolve_download_directory_from_inputs(
configured_download_dir: Option<&str>,
env_override: Option<&str>,
) -> Result<PathBuf, DownloadError> {
let raw = env_override
.or(configured_download_dir)
.map(str::trim)
.ok_or(DownloadError::PathNotConfigured)?;
if raw.is_empty() {
return Err(DownloadError::RomsDirEmpty);
}
let input_path = PathBuf::from(raw);
let normalized = if input_path.is_relative() {
std::env::current_dir()
.map_err(|e| DownloadError::IoContext {
context: "Could not resolve current working directory".into(),
source: e,
})?
.join(input_path)
} else {
input_path
};
if normalized.exists() && !normalized.is_dir() {
return Err(DownloadError::InvalidRomsDir {
path: normalized.display().to_string(),
});
}
std::fs::create_dir_all(&normalized).map_err(|e| DownloadError::IoContext {
context: format!(
"Could not create download directory {}",
normalized.display()
),
source: e,
})?;
let probe_name = format!(
".romm-write-test-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
);
let probe_path = normalized.join(probe_name);
let probe = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&probe_path)
.map_err(|e| DownloadError::IoContext {
context: format!("ROMs directory is not writable: {}", normalized.display()),
source: e,
})?;
drop(probe);
let _ = std::fs::remove_file(&probe_path);
Ok(normalized)
}
pub fn platform_download_slug(rom: &Rom) -> String {
rom.platform_fs_slug
.clone()
.or_else(|| rom.platform_slug.clone())
.unwrap_or_else(|| format!("platform-{}", rom.platform_id))
}
fn auto_console_roms_dir(base_download_dir: &Path, rom: &Rom) -> PathBuf {
base_download_dir.join(utils::sanitize_filename(&platform_download_slug(rom)))
}
pub fn resolve_console_roms_dir(
layout: &RomsLayoutConfig,
base_download_dir: &Path,
rom: &Rom,
) -> Result<PathBuf, DownloadError> {
if let Some(raw) = layout
.platform_dirs
.get(&rom.platform_id)
.map(|s| s.trim())
.filter(|s| !s.is_empty())
{
validate_configured_download_directory(raw)
} else {
Ok(auto_console_roms_dir(base_download_dir, rom))
}
}
fn save_platform_slug(
platform_id: u64,
platform_fs_slug: Option<&str>,
platform_slug: Option<&str>,
) -> String {
utils::sanitize_filename(
platform_fs_slug
.or(platform_slug)
.unwrap_or(&format!("platform-{platform_id}")),
)
}
fn auto_console_save_dir(
base_save_dir: &Path,
platform_id: u64,
platform_fs_slug: Option<&str>,
platform_slug: Option<&str>,
) -> PathBuf {
base_save_dir.join(save_platform_slug(
platform_id,
platform_fs_slug,
platform_slug,
))
}
pub fn resolve_console_save_dir(
save_sync: &SaveSyncConfig,
base_save_dir: &Path,
platform_id: u64,
platform_fs_slug: Option<&str>,
platform_slug: Option<&str>,
) -> Result<PathBuf, DownloadError> {
if let Some(raw) = save_sync
.platform_dirs
.get(&platform_id)
.map(|s| s.trim())
.filter(|s| !s.is_empty())
{
validate_configured_download_directory(raw)
} else {
Ok(auto_console_save_dir(
base_save_dir,
platform_id,
platform_fs_slug,
platform_slug,
))
}
}
fn safe_game_path_segment(input: &str) -> String {
let cleaned: String = input
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || matches!(c, ' ' | '-' | '_' | '.') {
c
} else {
'_'
}
})
.collect();
let trimmed = cleaned.trim().trim_matches('.').trim();
if trimmed.is_empty() {
"game".to_string()
} else {
trimmed.to_string()
}
}
pub fn resolve_game_save_dir(config: &Config, rom: &Rom) -> Result<PathBuf, DownloadError> {
let base = resolved_save_dir(config);
let console_dir = resolve_console_save_dir(
&config.save_sync,
&base,
rom.platform_id,
rom.platform_fs_slug.as_deref(),
rom.platform_slug.as_deref(),
)?;
Ok(console_dir.join(safe_game_path_segment(&rom.name)))
}
pub fn unique_zip_path(dir: &Path, stem: &str) -> PathBuf {
let mut n = 1u32;
loop {
let name = if n == 1 {
format!("{}.zip", stem)
} else {
format!("{}__{}.zip", stem, n)
};
let p = dir.join(name);
if !p.exists() {
return p;
}
n = n.saturating_add(1);
}
}
pub fn extract_zip_archive(zip_path: &Path, destination_dir: &Path) -> Result<(), DownloadError> {
let zip_path = zip_path.to_path_buf();
let destination_dir = destination_dir.to_path_buf();
std::fs::create_dir_all(&destination_dir).map_err(|e| DownloadError::IoContext {
context: format!(
"Could not create extraction directory {}",
destination_dir.display()
),
source: e,
})?;
let file = File::open(&zip_path).map_err(|e| DownloadError::IoContext {
context: format!("Could not open zip archive {}", zip_path.display()),
source: e,
})?;
let mut archive = ZipArchive::new(file).map_err(|e| DownloadError::IoContext {
context: format!("Invalid ZIP archive {}", zip_path.display()),
source: std::io::Error::new(std::io::ErrorKind::InvalidData, e),
})?;
for i in 0..archive.len() {
let mut entry = archive.by_index(i).map_err(|e| DownloadError::IoContext {
context: format!("Could not read zip entry {i} in {}", zip_path.display()),
source: io::Error::new(io::ErrorKind::InvalidData, e),
})?;
let enclosed_name = entry
.enclosed_name()
.ok_or_else(|| DownloadError::IoContext {
context: format!(
"Refusing to extract unsafe zip entry {:?} from {}",
entry.name(),
zip_path.display()
),
source: io::Error::new(io::ErrorKind::InvalidData, "unsafe zip entry path"),
})?;
if entry
.unix_mode()
.is_some_and(|mode| mode & 0o170000 == 0o120000)
{
return Err(DownloadError::IoContext {
context: format!(
"Refusing to extract symlink zip entry {:?} from {}",
entry.name(),
zip_path.display()
),
source: io::Error::new(io::ErrorKind::InvalidData, "zip symlink entry"),
});
}
let target = destination_dir.join(enclosed_name);
if entry.is_dir() {
std::fs::create_dir_all(&target).map_err(|e| DownloadError::IoContext {
context: format!("Could not create extracted directory {}", target.display()),
source: e,
})?;
continue;
}
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent).map_err(|e| DownloadError::IoContext {
context: format!("Could not create extracted parent {}", parent.display()),
source: e,
})?;
}
let mut out = File::create(&target).map_err(|e| DownloadError::IoContext {
context: format!("Could not create extracted file {}", target.display()),
source: e,
})?;
io::copy(&mut entry, &mut out).map_err(|e| DownloadError::IoContext {
context: format!("Could not write extracted file {}", target.display()),
source: e,
})?;
}
Ok(())
}