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.text().and_then(|t| t.trim().parse::<u64>().ok());
117                }
118                "hash" => {
119                    let hash_type = child.attribute("type").unwrap_or("").to_lowercase();
120                    let value = child.text().map(|t| t.trim().to_ascii_lowercase());
121                    match hash_type.as_str() {
122                        "sha-256" => sha256 = value,
123                        "sha-512" => sha512 = value,
124                        "md5" => md5 = value,
125                        _ => {}
126                    }
127                }
128                "url" => {
129                    let priority = child
130                        .attribute("priority")
131                        .and_then(|p| p.parse::<u32>().ok())
132                        .unwrap_or(999);
133                    if let Some(url_text) = child.text() {
134                        let url = url_text.trim().to_string();
135                        if !url.is_empty() {
136                            urls.push(MetalinkUrl { url, priority });
137                        }
138                    }
139                }
140                _ => {}
141            }
142        }
143
144        // Sort URLs: lowest priority number first.
145        urls.sort_by_key(|u| u.priority);
146
147        if !urls.is_empty() {
148            files.push(MetalinkFile {
149                name,
150                size,
151                sha256,
152                sha512,
153                md5,
154                urls,
155            });
156        }
157    }
158
159    if files.is_empty() {
160        return Err("Metalink contains no downloadable files with at least one URL".into());
161    }
162
163    Ok(MetalinkDoc { files })
164}
165
166// ============================================================================
167// Downloader
168// ============================================================================
169
170/// Download all files described in a Metalink source.
171///
172/// `source` may be:
173/// - A local file path ending in `.meta4` or `.metalink`
174/// - An HTTP/HTTPS URL pointing to a `.meta4` or `.metalink` file
175///
176/// Files are downloaded into `output_dir`, trying each mirror in priority
177/// order.  If a hash is present in the manifest, it is verified after the
178/// download completes.
179pub fn download_metalink(
180    source: &str,
181    output_dir: &str,
182    quiet: bool,
183    proxy: ProxyConfig,
184    optimizer: Optimizer,
185) -> Result<(), Box<dyn Error + Send + Sync>> {
186    let xml = fetch_manifest(source, quiet, &proxy)?;
187    let manifest = parse(&xml)?;
188
189    if !quiet {
190        println!("Metalink: {} file(s) to download", manifest.files.len());
191    }
192
193    let out_dir = PathBuf::from(output_dir);
194    if !out_dir.exists() {
195        std::fs::create_dir_all(&out_dir)?;
196    }
197
198    for file in &manifest.files {
199        download_one_file(file, &out_dir, quiet, &proxy, &optimizer)?;
200    }
201
202    Ok(())
203}
204
205// ============================================================================
206// Internal helpers
207// ============================================================================
208
209/// Fetch the metalink manifest XML — from disk or over HTTP.
210fn fetch_manifest(
211    source: &str,
212    quiet: bool,
213    proxy: &ProxyConfig,
214) -> Result<String, Box<dyn Error + Send + Sync>> {
215    if source.starts_with("http://") || source.starts_with("https://") {
216        if !quiet {
217            println!("Fetching Metalink manifest: {}", source);
218        }
219        let client = build_http_client(proxy)?;
220        let response = client.get(source).send()?;
221        if !response.status().is_success() {
222            return Err(format!(
223                "Failed to fetch Metalink manifest: HTTP {}",
224                response.status()
225            )
226            .into());
227        }
228        Ok(response.text()?)
229    } else {
230        std::fs::read_to_string(source)
231            .map_err(|e| format!("Cannot read Metalink file '{}': {}", source, e).into())
232    }
233}
234
235/// Build a blocking reqwest client with optional proxy support.
236fn build_http_client(
237    proxy: &ProxyConfig,
238) -> Result<reqwest::blocking::Client, Box<dyn Error + Send + Sync>> {
239    let mut builder = reqwest::blocking::Client::builder()
240        .timeout(std::time::Duration::from_secs(60))
241        .user_agent(concat!("KGet/", env!("CARGO_PKG_VERSION")));
242
243    if proxy.enabled
244        && let Some(proxy_url) = &proxy.url
245    {
246        let p = match proxy.proxy_type {
247            crate::config::ProxyType::Http => reqwest::Proxy::http(proxy_url),
248            crate::config::ProxyType::Https => reqwest::Proxy::https(proxy_url),
249            crate::config::ProxyType::Socks5 => reqwest::Proxy::all(proxy_url),
250        };
251        if let Ok(mut p) = p {
252            if let (Some(u), Some(pw)) = (&proxy.username, &proxy.password) {
253                p = p.basic_auth(u, pw);
254            }
255            builder = builder.proxy(p);
256        }
257    }
258
259    Ok(builder.build()?)
260}
261
262/// Download a single metalink file, trying each mirror in order.
263fn download_one_file(
264    file: &MetalinkFile,
265    output_dir: &Path,
266    quiet: bool,
267    proxy: &ProxyConfig,
268    optimizer: &Optimizer,
269) -> Result<(), Box<dyn Error + Send + Sync>> {
270    let safe_name = sanitise_filename(&file.name);
271    let dest = output_dir.join(&safe_name);
272
273    if !quiet {
274        println!(
275            "\nDownloading '{}' ({} mirror(s)){}",
276            safe_name,
277            file.urls.len(),
278            file.size
279                .map(|s| format!("  [{} bytes]", s))
280                .unwrap_or_default()
281        );
282    }
283
284    let mut last_error: Option<Box<dyn Error + Send + Sync>> = None;
285
286    for (idx, mirror) in file.urls.iter().enumerate() {
287        if !quiet {
288            println!("  Mirror {}/{}: {}", idx + 1, file.urls.len(), mirror.url);
289        }
290
291        match download_from_mirror(&mirror.url, &dest, quiet, proxy, optimizer) {
292            Ok(()) => {
293                if !quiet {
294                    println!("  Download OK");
295                }
296
297                // Verify hash if the manifest provides one.
298                if let Some((hash_type, expected)) = file.best_hash() {
299                    if hash_type == "sha-256" {
300                        if !quiet {
301                            println!("  Verifying SHA-256...");
302                        }
303                        match verify_file_sha256(&dest, Some(expected), None) {
304                            Ok(_) => {
305                                if !quiet {
306                                    println!("  SHA-256 OK ✓");
307                                }
308                            }
309                            Err(e) => {
310                                eprintln!("  SHA-256 mismatch: {}", e);
311                                // Remove corrupted file and try next mirror.
312                                let _ = std::fs::remove_file(&dest);
313                                last_error = Some(e);
314                                continue;
315                            }
316                        }
317                    } else if !quiet {
318                        println!(
319                            "  Skipping {} verification (only sha-256 supported inline)",
320                            hash_type
321                        );
322                    }
323                }
324
325                return Ok(());
326            }
327            Err(e) => {
328                if !quiet {
329                    eprintln!("  Mirror failed: {}", e);
330                }
331                last_error = Some(e);
332            }
333        }
334    }
335
336    Err(last_error.unwrap_or_else(|| "All mirrors failed".into()))
337}
338
339/// Attempt to download a single URL to `dest` using AdvancedDownloader.
340fn download_from_mirror(
341    url: &str,
342    dest: &Path,
343    quiet: bool,
344    proxy: &ProxyConfig,
345    optimizer: &Optimizer,
346) -> Result<(), Box<dyn Error + Send + Sync>> {
347    use crate::advanced_download::AdvancedDownloader;
348
349    let output_path = dest
350        .to_str()
351        .ok_or("Output path contains non-UTF-8 characters")?
352        .to_string();
353
354    let dl = AdvancedDownloader::new(
355        url.to_string(),
356        output_path,
357        quiet,
358        proxy.clone(),
359        optimizer.clone(),
360    )?;
361    dl.download()
362}
363
364/// Remove characters that are unsafe in filenames across platforms.
365fn sanitise_filename(name: &str) -> String {
366    let cleaned: String = name
367        .chars()
368        .map(|c| match c {
369            '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' | '\0' => '_',
370            c => c,
371        })
372        .collect();
373
374    let cleaned = cleaned.trim_matches('.').trim();
375    if cleaned.is_empty() {
376        "download".to_string()
377    } else {
378        cleaned.to_string()
379    }
380}
381
382// ============================================================================
383// Convenience re-check: is this path/URL a Metalink?
384// ============================================================================
385
386/// Return `true` if `source` looks like a local or remote Metalink file.
387pub fn is_metalink(source: &str) -> bool {
388    let lower = source.to_lowercase();
389    // Strip query string for URL detection
390    let base = lower.split('?').next().unwrap_or(&lower);
391    base.ends_with(".meta4") || base.ends_with(".metalink")
392}