Skip to main content

kget/metalink/
mod.rs

1//! Metalink (.meta4 / .metalink) download support.
2//!
3//! Metalink is a standard XML format (RFC 5854 for `.meta4`) that describes a
4//! file along with multiple download mirrors and cryptographic checksums.
5//! KGet parses the manifest, tries each mirror in priority order, and verifies
6//! the hash after a successful download — all automatically.
7//!
8//! # Supported features
9//! - Multiple mirrors with optional `priority` attribute (lower = preferred)
10//! - `sha-256`, `sha-512`, and `md5` hash types (sha-256 preferred)
11//! - Multiple `<file>` entries per manifest
12//! - Local `.meta4` files and remote `.meta4` URLs
13//!
14//! # Example
15//!
16//! ```rust,no_run
17//! use kget::metalink::download_metalink;
18//! use kget::{ProxyConfig, Optimizer};
19//!
20//! download_metalink(
21//!     "ubuntu-24.04.meta4",
22//!     "~/Downloads",
23//!     false,
24//!     ProxyConfig::default(),
25//!     Optimizer::new(),
26//! ).unwrap();
27//! ```
28
29use crate::config::ProxyConfig;
30use crate::download::verify_file_sha256;
31use crate::optimization::Optimizer;
32use std::error::Error;
33use std::path::{Path, PathBuf};
34
35// ============================================================================
36// Public data model
37// ============================================================================
38
39/// A single download URL with an optional priority (lower number = tried first).
40#[derive(Debug, Clone)]
41pub struct MetalinkUrl {
42    pub url: String,
43    /// Lower priority number means the mirror is tried first.
44    /// Defaults to 999 if the attribute is absent.
45    pub priority: u32,
46}
47
48/// A single file described in a Metalink manifest.
49#[derive(Debug, Clone)]
50pub struct MetalinkFile {
51    pub name: String,
52    pub size: Option<u64>,
53    pub sha256: Option<String>,
54    pub sha512: Option<String>,
55    pub md5: Option<String>,
56    /// Mirrors sorted by priority (ascending).
57    pub urls: Vec<MetalinkUrl>,
58}
59
60impl MetalinkFile {
61    /// Return the best available hash as `(type_label, hex_string)`.
62    /// Preference order: sha-256 → sha-512 → md5.
63    pub fn best_hash(&self) -> Option<(&str, &str)> {
64        if let Some(h) = &self.sha256 {
65            return Some(("sha-256", h.as_str()));
66        }
67        if let Some(h) = &self.sha512 {
68            return Some(("sha-512", h.as_str()));
69        }
70        if let Some(h) = &self.md5 {
71            return Some(("md5", h.as_str()));
72        }
73        None
74    }
75}
76
77/// A parsed Metalink manifest, potentially containing multiple files.
78#[derive(Debug, Clone)]
79pub struct MetalinkDoc {
80    pub files: Vec<MetalinkFile>,
81}
82
83// ============================================================================
84// Parser
85// ============================================================================
86
87/// Parse a `.meta4` or `.metalink` XML string into a [`MetalinkDoc`].
88///
89/// Both the RFC 5854 namespace (`urn:ietf:params:xml:ns:metalink`) and the
90/// older Metalink 3.x format (no namespace) are accepted.
91pub fn parse(content: &str) -> Result<MetalinkDoc, Box<dyn Error + Send + Sync>> {
92    let doc = roxmltree::Document::parse(content)
93        .map_err(|e| format!("Metalink XML parse error: {}", e))?;
94
95    let root = doc.root_element();
96    let mut files: Vec<MetalinkFile> = Vec::new();
97
98    for file_node in root
99        .children()
100        .filter(|n| n.is_element() && n.tag_name().name() == "file")
101    {
102        let name = file_node
103            .attribute("name")
104            .unwrap_or("download")
105            .to_string();
106
107        let mut size: Option<u64> = None;
108        let mut sha256: Option<String> = None;
109        let mut sha512: Option<String> = None;
110        let mut md5: Option<String> = None;
111        let mut urls: Vec<MetalinkUrl> = Vec::new();
112
113        for child in file_node.children().filter(|n| n.is_element()) {
114            match child.tag_name().name() {
115                "size" => {
116                    size = child
117                        .text()
118                        .and_then(|t| t.trim().parse::<u64>().ok());
119                }
120                "hash" => {
121                    let hash_type = child.attribute("type").unwrap_or("").to_lowercase();
122                    let value = child.text().map(|t| t.trim().to_ascii_lowercase());
123                    match hash_type.as_str() {
124                        "sha-256" => sha256 = value,
125                        "sha-512" => sha512 = value,
126                        "md5" => md5 = value,
127                        _ => {}
128                    }
129                }
130                "url" => {
131                    let priority = child
132                        .attribute("priority")
133                        .and_then(|p| p.parse::<u32>().ok())
134                        .unwrap_or(999);
135                    if let Some(url_text) = child.text() {
136                        let url = url_text.trim().to_string();
137                        if !url.is_empty() {
138                            urls.push(MetalinkUrl { url, priority });
139                        }
140                    }
141                }
142                _ => {}
143            }
144        }
145
146        // Sort URLs: lowest priority number first.
147        urls.sort_by_key(|u| u.priority);
148
149        if !urls.is_empty() {
150            files.push(MetalinkFile {
151                name,
152                size,
153                sha256,
154                sha512,
155                md5,
156                urls,
157            });
158        }
159    }
160
161    if files.is_empty() {
162        return Err(
163            "Metalink contains no downloadable files with at least one URL".into(),
164        );
165    }
166
167    Ok(MetalinkDoc { files })
168}
169
170// ============================================================================
171// Downloader
172// ============================================================================
173
174/// Download all files described in a Metalink source.
175///
176/// `source` may be:
177/// - A local file path ending in `.meta4` or `.metalink`
178/// - An HTTP/HTTPS URL pointing to a `.meta4` or `.metalink` file
179///
180/// Files are downloaded into `output_dir`, trying each mirror in priority
181/// order.  If a hash is present in the manifest, it is verified after the
182/// download completes.
183pub fn download_metalink(
184    source: &str,
185    output_dir: &str,
186    quiet: bool,
187    proxy: ProxyConfig,
188    optimizer: Optimizer,
189) -> Result<(), Box<dyn Error + Send + Sync>> {
190    let xml = fetch_manifest(source, quiet, &proxy)?;
191    let manifest = parse(&xml)?;
192
193    if !quiet {
194        println!(
195            "Metalink: {} file(s) to download",
196            manifest.files.len()
197        );
198    }
199
200    let out_dir = PathBuf::from(output_dir);
201    if !out_dir.exists() {
202        std::fs::create_dir_all(&out_dir)?;
203    }
204
205    for file in &manifest.files {
206        download_one_file(file, &out_dir, quiet, &proxy, &optimizer)?;
207    }
208
209    Ok(())
210}
211
212// ============================================================================
213// Internal helpers
214// ============================================================================
215
216/// Fetch the metalink manifest XML — from disk or over HTTP.
217fn fetch_manifest(
218    source: &str,
219    quiet: bool,
220    proxy: &ProxyConfig,
221) -> Result<String, Box<dyn Error + Send + Sync>> {
222    if source.starts_with("http://") || source.starts_with("https://") {
223        if !quiet {
224            println!("Fetching Metalink manifest: {}", source);
225        }
226        let client = build_http_client(proxy)?;
227        let response = client.get(source).send()?;
228        if !response.status().is_success() {
229            return Err(format!(
230                "Failed to fetch Metalink manifest: HTTP {}",
231                response.status()
232            )
233            .into());
234        }
235        Ok(response.text()?)
236    } else {
237        std::fs::read_to_string(source)
238            .map_err(|e| format!("Cannot read Metalink file '{}': {}", source, e).into())
239    }
240}
241
242/// Build a blocking reqwest client with optional proxy support.
243fn build_http_client(
244    proxy: &ProxyConfig,
245) -> Result<reqwest::blocking::Client, Box<dyn Error + Send + Sync>> {
246    let mut builder = reqwest::blocking::Client::builder()
247        .timeout(std::time::Duration::from_secs(60))
248        .user_agent(concat!("KGet/", env!("CARGO_PKG_VERSION")));
249
250    if proxy.enabled && let Some(proxy_url) = &proxy.url {
251        let p = match proxy.proxy_type {
252            crate::config::ProxyType::Http => reqwest::Proxy::http(proxy_url),
253            crate::config::ProxyType::Https => reqwest::Proxy::https(proxy_url),
254            crate::config::ProxyType::Socks5 => reqwest::Proxy::all(proxy_url),
255        };
256        if let Ok(mut p) = p {
257            if let (Some(u), Some(pw)) = (&proxy.username, &proxy.password) {
258                p = p.basic_auth(u, pw);
259            }
260            builder = builder.proxy(p);
261        }
262    }
263
264    Ok(builder.build()?)
265}
266
267/// Download a single metalink file, trying each mirror in order.
268fn download_one_file(
269    file: &MetalinkFile,
270    output_dir: &Path,
271    quiet: bool,
272    proxy: &ProxyConfig,
273    optimizer: &Optimizer,
274) -> Result<(), Box<dyn Error + Send + Sync>> {
275    let safe_name = sanitise_filename(&file.name);
276    let dest = output_dir.join(&safe_name);
277
278    if !quiet {
279        println!(
280            "\nDownloading '{}' ({} mirror(s)){}",
281            safe_name,
282            file.urls.len(),
283            file.size
284                .map(|s| format!("  [{} bytes]", s))
285                .unwrap_or_default()
286        );
287    }
288
289    let mut last_error: Option<Box<dyn Error + Send + Sync>> = None;
290
291    for (idx, mirror) in file.urls.iter().enumerate() {
292        if !quiet {
293            println!(
294                "  Mirror {}/{}: {}",
295                idx + 1,
296                file.urls.len(),
297                mirror.url
298            );
299        }
300
301        match download_from_mirror(&mirror.url, &dest, quiet, proxy, optimizer) {
302            Ok(()) => {
303                if !quiet {
304                    println!("  Download OK");
305                }
306
307                // Verify hash if the manifest provides one.
308                if let Some((hash_type, expected)) = file.best_hash() {
309                    if hash_type == "sha-256" {
310                        if !quiet {
311                            println!("  Verifying SHA-256...");
312                        }
313                        match verify_file_sha256(&dest, Some(expected), None) {
314                            Ok(_) => {
315                                if !quiet {
316                                    println!("  SHA-256 OK ✓");
317                                }
318                            }
319                            Err(e) => {
320                                eprintln!("  SHA-256 mismatch: {}", e);
321                                // Remove corrupted file and try next mirror.
322                                let _ = std::fs::remove_file(&dest);
323                                last_error = Some(e);
324                                continue;
325                            }
326                        }
327                    } else if !quiet {
328                        println!(
329                            "  Skipping {} verification (only sha-256 supported inline)",
330                            hash_type
331                        );
332                    }
333                }
334
335                return Ok(());
336            }
337            Err(e) => {
338                if !quiet {
339                    eprintln!("  Mirror failed: {}", e);
340                }
341                last_error = Some(e);
342            }
343        }
344    }
345
346    Err(last_error.unwrap_or_else(|| "All mirrors failed".into()))
347}
348
349/// Attempt to download a single URL to `dest` using AdvancedDownloader.
350fn download_from_mirror(
351    url: &str,
352    dest: &Path,
353    quiet: bool,
354    proxy: &ProxyConfig,
355    optimizer: &Optimizer,
356) -> Result<(), Box<dyn Error + Send + Sync>> {
357    use crate::advanced_download::AdvancedDownloader;
358
359    let output_path = dest
360        .to_str()
361        .ok_or("Output path contains non-UTF-8 characters")?
362        .to_string();
363
364    let dl = AdvancedDownloader::new(
365        url.to_string(),
366        output_path,
367        quiet,
368        proxy.clone(),
369        optimizer.clone(),
370    );
371    dl.download()
372}
373
374/// Remove characters that are unsafe in filenames across platforms.
375fn sanitise_filename(name: &str) -> String {
376    let cleaned: String = name
377        .chars()
378        .map(|c| match c {
379            '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' | '\0' => '_',
380            c => c,
381        })
382        .collect();
383
384    let cleaned = cleaned.trim_matches('.').trim();
385    if cleaned.is_empty() {
386        "download".to_string()
387    } else {
388        cleaned.to_string()
389    }
390}
391
392// ============================================================================
393// Convenience re-check: is this path/URL a Metalink?
394// ============================================================================
395
396/// Return `true` if `source` looks like a local or remote Metalink file.
397pub fn is_metalink(source: &str) -> bool {
398    let lower = source.to_lowercase();
399    // Strip query string for URL detection
400    let base = lower.split('?').next().unwrap_or(&lower);
401    base.ends_with(".meta4") || base.ends_with(".metalink")
402}