Skip to main content

romm_api/core/download/
paths.rs

1//! ROM/save path resolution and zip extraction helpers.
2
3use 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
14/// Directory for ROM storage (`ROMM_ROMS_DIR`, `ROMM_DOWNLOAD_DIR`, or configured path).
15pub 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
24/// Validate configured download path without env override fallback.
25pub 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
31/// Backward-compatible default used by legacy CLI download code.
32pub 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
100/// Filesystem slug used for auto-mode console subfolders.
101pub 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
112/// Resolve the directory where ROM files for `rom` should be stored.
113pub 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
155/// Resolve the directory where save files for a console should be stored.
156pub 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
199/// Resolve the directory where a specific game's saves should be downloaded.
200pub 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
212/// Pick `stem.zip`, then `stem__2.zip`, `stem__3.zip`, … until the path does not exist.
213pub 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
229/// Extract a ZIP archive into `destination_dir`.
230pub 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}