1use std::io;
4use std::path::{Path, PathBuf};
5
6use crate::config::RomsLayoutConfig;
7use crate::config::{resolved_save_dir, Config, SaveSyncConfig};
8use crate::core::utils;
9use crate::error::DownloadError;
10use crate::types::Rom;
11use std::fs::File;
12use zip::ZipArchive;
13
14pub fn resolve_download_directory(
16 configured_download_dir: Option<&str>,
17) -> Result<PathBuf, DownloadError> {
18 let env_override = std::env::var("ROMM_ROMS_DIR")
19 .ok()
20 .or_else(|| std::env::var("ROMM_DOWNLOAD_DIR").ok());
21 resolve_download_directory_from_inputs(configured_download_dir, env_override.as_deref())
22}
23
24pub fn validate_configured_download_directory(
26 configured_download_dir: &str,
27) -> Result<PathBuf, DownloadError> {
28 resolve_download_directory_from_inputs(Some(configured_download_dir), None)
29}
30
31pub fn download_directory() -> PathBuf {
33 std::env::var("ROMM_ROMS_DIR")
34 .or_else(|_| std::env::var("ROMM_DOWNLOAD_DIR"))
35 .map(PathBuf::from)
36 .unwrap_or_else(|_| PathBuf::from("./downloads"))
37}
38
39pub(crate) fn resolve_download_directory_from_inputs(
40 configured_download_dir: Option<&str>,
41 env_override: Option<&str>,
42) -> Result<PathBuf, DownloadError> {
43 let raw = env_override
44 .or(configured_download_dir)
45 .map(str::trim)
46 .ok_or(DownloadError::PathNotConfigured)?;
47
48 if raw.is_empty() {
49 return Err(DownloadError::RomsDirEmpty);
50 }
51
52 let input_path = PathBuf::from(raw);
53 let normalized = if input_path.is_relative() {
54 std::env::current_dir()
55 .map_err(|e| DownloadError::IoContext {
56 context: "Could not resolve current working directory".into(),
57 source: e,
58 })?
59 .join(input_path)
60 } else {
61 input_path
62 };
63
64 if normalized.exists() && !normalized.is_dir() {
65 return Err(DownloadError::InvalidRomsDir {
66 path: normalized.display().to_string(),
67 });
68 }
69
70 std::fs::create_dir_all(&normalized).map_err(|e| DownloadError::IoContext {
71 context: format!(
72 "Could not create download directory {}",
73 normalized.display()
74 ),
75 source: e,
76 })?;
77
78 let probe_name = format!(
79 ".romm-write-test-{}",
80 std::time::SystemTime::now()
81 .duration_since(std::time::UNIX_EPOCH)
82 .unwrap_or_default()
83 .as_nanos()
84 );
85 let probe_path = normalized.join(probe_name);
86 let probe = std::fs::OpenOptions::new()
87 .write(true)
88 .create_new(true)
89 .open(&probe_path)
90 .map_err(|e| DownloadError::IoContext {
91 context: format!("ROMs directory is not writable: {}", normalized.display()),
92 source: e,
93 })?;
94 drop(probe);
95 let _ = std::fs::remove_file(&probe_path);
96
97 Ok(normalized)
98}
99
100pub fn platform_download_slug(rom: &Rom) -> String {
102 rom.platform_fs_slug
103 .clone()
104 .or_else(|| rom.platform_slug.clone())
105 .unwrap_or_else(|| format!("platform-{}", rom.platform_id))
106}
107
108fn auto_console_roms_dir(base_download_dir: &Path, rom: &Rom) -> PathBuf {
109 base_download_dir.join(utils::sanitize_filename(&platform_download_slug(rom)))
110}
111
112pub fn resolve_console_roms_dir(
114 layout: &RomsLayoutConfig,
115 base_download_dir: &Path,
116 rom: &Rom,
117) -> Result<PathBuf, DownloadError> {
118 if let Some(raw) = layout
119 .platform_dirs
120 .get(&rom.platform_id)
121 .map(|s| s.trim())
122 .filter(|s| !s.is_empty())
123 {
124 validate_configured_download_directory(raw)
125 } else {
126 Ok(auto_console_roms_dir(base_download_dir, rom))
127 }
128}
129
130fn save_platform_slug(
131 platform_id: u64,
132 platform_fs_slug: Option<&str>,
133 platform_slug: Option<&str>,
134) -> String {
135 utils::sanitize_filename(
136 platform_fs_slug
137 .or(platform_slug)
138 .unwrap_or(&format!("platform-{platform_id}")),
139 )
140}
141
142fn auto_console_save_dir(
143 base_save_dir: &Path,
144 platform_id: u64,
145 platform_fs_slug: Option<&str>,
146 platform_slug: Option<&str>,
147) -> PathBuf {
148 base_save_dir.join(save_platform_slug(
149 platform_id,
150 platform_fs_slug,
151 platform_slug,
152 ))
153}
154
155pub fn resolve_console_save_dir(
157 save_sync: &SaveSyncConfig,
158 base_save_dir: &Path,
159 platform_id: u64,
160 platform_fs_slug: Option<&str>,
161 platform_slug: Option<&str>,
162) -> Result<PathBuf, DownloadError> {
163 if let Some(raw) = save_sync
164 .platform_dirs
165 .get(&platform_id)
166 .map(|s| s.trim())
167 .filter(|s| !s.is_empty())
168 {
169 validate_configured_download_directory(raw)
170 } else {
171 Ok(auto_console_save_dir(
172 base_save_dir,
173 platform_id,
174 platform_fs_slug,
175 platform_slug,
176 ))
177 }
178}
179
180fn safe_game_path_segment(input: &str) -> String {
181 let cleaned: String = input
182 .chars()
183 .map(|c| {
184 if c.is_ascii_alphanumeric() || matches!(c, ' ' | '-' | '_' | '.') {
185 c
186 } else {
187 '_'
188 }
189 })
190 .collect();
191 let trimmed = cleaned.trim().trim_matches('.').trim();
192 if trimmed.is_empty() {
193 "game".to_string()
194 } else {
195 trimmed.to_string()
196 }
197}
198
199pub fn resolve_game_save_dir(config: &Config, rom: &Rom) -> Result<PathBuf, DownloadError> {
201 let base = resolved_save_dir(config);
202 let console_dir = resolve_console_save_dir(
203 &config.save_sync,
204 &base,
205 rom.platform_id,
206 rom.platform_fs_slug.as_deref(),
207 rom.platform_slug.as_deref(),
208 )?;
209 Ok(console_dir.join(safe_game_path_segment(&rom.name)))
210}
211
212pub fn unique_zip_path(dir: &Path, stem: &str) -> PathBuf {
214 let mut n = 1u32;
215 loop {
216 let name = if n == 1 {
217 format!("{}.zip", stem)
218 } else {
219 format!("{}__{}.zip", stem, n)
220 };
221 let p = dir.join(name);
222 if !p.exists() {
223 return p;
224 }
225 n = n.saturating_add(1);
226 }
227}
228
229pub fn extract_zip_archive(zip_path: &Path, destination_dir: &Path) -> Result<(), DownloadError> {
231 let zip_path = zip_path.to_path_buf();
232 let destination_dir = destination_dir.to_path_buf();
233 std::fs::create_dir_all(&destination_dir).map_err(|e| DownloadError::IoContext {
234 context: format!(
235 "Could not create extraction directory {}",
236 destination_dir.display()
237 ),
238 source: e,
239 })?;
240
241 let file = File::open(&zip_path).map_err(|e| DownloadError::IoContext {
242 context: format!("Could not open zip archive {}", zip_path.display()),
243 source: e,
244 })?;
245 let mut archive = ZipArchive::new(file).map_err(|e| DownloadError::IoContext {
246 context: format!("Invalid ZIP archive {}", zip_path.display()),
247 source: std::io::Error::new(std::io::ErrorKind::InvalidData, e),
248 })?;
249 for i in 0..archive.len() {
250 let mut entry = archive.by_index(i).map_err(|e| DownloadError::IoContext {
251 context: format!("Could not read zip entry {i} in {}", zip_path.display()),
252 source: io::Error::new(io::ErrorKind::InvalidData, e),
253 })?;
254 let enclosed_name = entry
255 .enclosed_name()
256 .ok_or_else(|| DownloadError::IoContext {
257 context: format!(
258 "Refusing to extract unsafe zip entry {:?} from {}",
259 entry.name(),
260 zip_path.display()
261 ),
262 source: io::Error::new(io::ErrorKind::InvalidData, "unsafe zip entry path"),
263 })?;
264 if entry
265 .unix_mode()
266 .is_some_and(|mode| mode & 0o170000 == 0o120000)
267 {
268 return Err(DownloadError::IoContext {
269 context: format!(
270 "Refusing to extract symlink zip entry {:?} from {}",
271 entry.name(),
272 zip_path.display()
273 ),
274 source: io::Error::new(io::ErrorKind::InvalidData, "zip symlink entry"),
275 });
276 }
277
278 let target = destination_dir.join(enclosed_name);
279 if entry.is_dir() {
280 std::fs::create_dir_all(&target).map_err(|e| DownloadError::IoContext {
281 context: format!("Could not create extracted directory {}", target.display()),
282 source: e,
283 })?;
284 continue;
285 }
286
287 if let Some(parent) = target.parent() {
288 std::fs::create_dir_all(parent).map_err(|e| DownloadError::IoContext {
289 context: format!("Could not create extracted parent {}", parent.display()),
290 source: e,
291 })?;
292 }
293 let mut out = File::create(&target).map_err(|e| DownloadError::IoContext {
294 context: format!("Could not create extracted file {}", target.display()),
295 source: e,
296 })?;
297 io::copy(&mut entry, &mut out).map_err(|e| DownloadError::IoContext {
298 context: format!("Could not write extracted file {}", target.display()),
299 source: e,
300 })?;
301 }
302 Ok(())
303}