Skip to main content

sal_os/
download.rs

1use std::error::Error;
2use std::fmt;
3use std::fs;
4use std::io;
5use std::path::Path;
6use std::process::Command;
7
8// Define a custom error type for download operations
9#[derive(Debug)]
10pub enum DownloadError {
11    CreateDirectoryFailed(io::Error),
12    CurlExecutionFailed(io::Error),
13    DownloadFailed(String),
14    FileMetadataError(io::Error),
15    FileTooSmall(i64, i64),
16    RemoveFileFailed(io::Error),
17    ExtractionFailed(String),
18    CommandExecutionFailed(io::Error),
19    InvalidUrl(String),
20    NotAFile(String),
21    PlatformNotSupported(String),
22    InstallationFailed(String),
23}
24
25// Implement Display for DownloadError
26impl fmt::Display for DownloadError {
27    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
28        match self {
29            DownloadError::CreateDirectoryFailed(e) => {
30                write!(f, "Error creating directories: {}", e)
31            }
32            DownloadError::CurlExecutionFailed(e) => write!(f, "Error executing curl: {}", e),
33            DownloadError::DownloadFailed(url) => write!(f, "Error downloading url: {}", url),
34            DownloadError::FileMetadataError(e) => write!(f, "Error getting file metadata: {}", e),
35            DownloadError::FileTooSmall(size, min) => write!(
36                f,
37                "Error: Downloaded file is too small ({}KB < {}KB)",
38                size, min
39            ),
40            DownloadError::RemoveFileFailed(e) => write!(f, "Error removing file: {}", e),
41            DownloadError::ExtractionFailed(e) => write!(f, "Error extracting archive: {}", e),
42            DownloadError::CommandExecutionFailed(e) => write!(f, "Error executing command: {}", e),
43            DownloadError::InvalidUrl(url) => write!(f, "Invalid URL: {}", url),
44            DownloadError::NotAFile(path) => write!(f, "Not a file: {}", path),
45            DownloadError::PlatformNotSupported(msg) => write!(f, "{}", msg),
46            DownloadError::InstallationFailed(msg) => write!(f, "{}", msg),
47        }
48    }
49}
50
51// Implement Error trait for DownloadError
52impl Error for DownloadError {
53    fn source(&self) -> Option<&(dyn Error + 'static)> {
54        match self {
55            DownloadError::CreateDirectoryFailed(e) => Some(e),
56            DownloadError::CurlExecutionFailed(e) => Some(e),
57            DownloadError::FileMetadataError(e) => Some(e),
58            DownloadError::RemoveFileFailed(e) => Some(e),
59            DownloadError::CommandExecutionFailed(e) => Some(e),
60            _ => None,
61        }
62    }
63}
64
65/**
66 * Download a file from URL to destination using the curl command.
67 * This function is primarily intended for downloading archives that will be extracted
68 * to a directory.
69 *
70 * # Arguments
71 *
72 * * `url` - The URL to download from
73 * * `dest` - The destination directory where the file will be saved or extracted
74 * * `min_size_kb` - Minimum required file size in KB (0 for no minimum)
75 *
76 * # Returns
77 *
78 * * `Ok(String)` - The path where the file was saved or extracted
79 * * `Err(DownloadError)` - An error if the download failed
80 *
81 * # Examples
82 *
83 * ```no_run
84 * use sal_os::download;
85 *
86 * fn main() -> Result<(), Box<dyn std::error::Error>> {
87 *     // Download a file with no minimum size requirement
88 *     let path = download("https://example.com/file.txt", "/tmp/", 0)?;
89 *
90 *     // Download a file with minimum size requirement of 100KB
91 *     let path = download("https://example.com/file.zip", "/tmp/", 100)?;
92 *
93 *     Ok(())
94 * }
95 * ```
96 *
97 * # Notes
98 *
99 * If the URL ends with .tar.gz, .tgz, .tar, or .zip, the file will be automatically
100 * extracted to the destination directory.
101 */
102pub fn download(url: &str, dest: &str, min_size_kb: i64) -> Result<String, DownloadError> {
103    // Create parent directories if they don't exist
104    let dest_path = Path::new(dest);
105    fs::create_dir_all(dest_path).map_err(DownloadError::CreateDirectoryFailed)?;
106
107    // Extract filename from URL
108    let filename = match url.split('/').last() {
109        Some(name) => name,
110        None => {
111            return Err(DownloadError::InvalidUrl(
112                "cannot extract filename".to_string(),
113            ))
114        }
115    };
116
117    // Create a full path for the downloaded file
118    let file_path = format!("{}/{}", dest.trim_end_matches('/'), filename);
119
120    // Create a temporary path for downloading
121    let temp_path = format!("{}.download", file_path);
122
123    // Use curl to download the file with progress bar
124    println!("Downloading {} to {}", url, file_path);
125    let output = Command::new("curl")
126        .args(&[
127            "--progress-bar",
128            "--location",
129            "--fail",
130            "--output",
131            &temp_path,
132            url,
133        ])
134        .status()
135        .map_err(DownloadError::CurlExecutionFailed)?;
136
137    if !output.success() {
138        return Err(DownloadError::DownloadFailed(url.to_string()));
139    }
140
141    // Show file size after download
142    match fs::metadata(&temp_path) {
143        Ok(metadata) => {
144            let size_bytes = metadata.len();
145            let size_kb = size_bytes / 1024;
146            let size_mb = size_kb / 1024;
147            if size_mb > 1 {
148                println!(
149                    "Download complete! File size: {:.2} MB",
150                    size_bytes as f64 / (1024.0 * 1024.0)
151                );
152            } else {
153                println!(
154                    "Download complete! File size: {:.2} KB",
155                    size_bytes as f64 / 1024.0
156                );
157            }
158        }
159        Err(_) => println!("Download complete!"),
160    }
161
162    // Check file size if minimum size is specified
163    if min_size_kb > 0 {
164        let metadata = fs::metadata(&temp_path).map_err(DownloadError::FileMetadataError)?;
165        let size_kb = metadata.len() as i64 / 1024;
166        if size_kb < min_size_kb {
167            fs::remove_file(&temp_path).map_err(DownloadError::RemoveFileFailed)?;
168            return Err(DownloadError::FileTooSmall(size_kb, min_size_kb));
169        }
170    }
171
172    // Check if it's a compressed file that needs extraction
173    let lower_url = url.to_lowercase();
174    let is_archive = lower_url.ends_with(".tar.gz")
175        || lower_url.ends_with(".tgz")
176        || lower_url.ends_with(".tar")
177        || lower_url.ends_with(".zip");
178
179    if is_archive {
180        // Extract the file using the appropriate command with progress indication
181        println!("Extracting {} to {}", temp_path, dest);
182        let output = if lower_url.ends_with(".zip") {
183            Command::new("unzip")
184                .args(&["-o", &temp_path, "-d", dest]) // Removed -q for verbosity
185                .status()
186        } else if lower_url.ends_with(".tar.gz") || lower_url.ends_with(".tgz") {
187            Command::new("tar")
188                .args(&["-xzvf", &temp_path, "-C", dest]) // Added v for verbosity
189                .status()
190        } else {
191            Command::new("tar")
192                .args(&["-xvf", &temp_path, "-C", dest]) // Added v for verbosity
193                .status()
194        };
195
196        match output {
197            Ok(status) => {
198                if !status.success() {
199                    return Err(DownloadError::ExtractionFailed(
200                        "Error extracting archive".to_string(),
201                    ));
202                }
203            }
204            Err(e) => return Err(DownloadError::CommandExecutionFailed(e)),
205        }
206
207        // Show number of extracted files
208        match fs::read_dir(dest) {
209            Ok(entries) => {
210                let count = entries.count();
211                println!("Extraction complete! Extracted {} files/directories", count);
212            }
213            Err(_) => println!("Extraction complete!"),
214        }
215
216        // Remove the temporary file
217        fs::remove_file(&temp_path).map_err(DownloadError::RemoveFileFailed)?;
218
219        Ok(dest.to_string())
220    } else {
221        // Just rename the temporary file to the final destination
222        fs::rename(&temp_path, &file_path).map_err(|e| DownloadError::CreateDirectoryFailed(e))?;
223
224        Ok(file_path)
225    }
226}
227
228/**
229 * Download a file from URL to a specific file destination using the curl command.
230 *
231 * # Arguments
232 *
233 * * `url` - The URL to download from
234 * * `dest` - The destination file path where the file will be saved
235 * * `min_size_kb` - Minimum required file size in KB (0 for no minimum)
236 *
237 * # Returns
238 *
239 * * `Ok(String)` - The path where the file was saved
240 * * `Err(DownloadError)` - An error if the download failed
241 *
242 * # Examples
243 *
244 * ```no_run
245 * use sal_os::download_file;
246 *
247 * fn main() -> Result<(), Box<dyn std::error::Error>> {
248 *     // Download a file with no minimum size requirement
249 *     let path = download_file("https://example.com/file.txt", "/tmp/file.txt", 0)?;
250 *
251 *     // Download a file with minimum size requirement of 100KB
252 *     let path = download_file("https://example.com/file.zip", "/tmp/file.zip", 100)?;
253 *
254 *     Ok(())
255 * }
256 * ```
257 */
258pub fn download_file(url: &str, dest: &str, min_size_kb: i64) -> Result<String, DownloadError> {
259    // Create parent directories if they don't exist
260    let dest_path = Path::new(dest);
261    if let Some(parent) = dest_path.parent() {
262        fs::create_dir_all(parent).map_err(DownloadError::CreateDirectoryFailed)?;
263    }
264
265    // Create a temporary path for downloading
266    let temp_path = format!("{}.download", dest);
267
268    // Use curl to download the file with progress bar
269    println!("Downloading {} to {}", url, dest);
270    let output = Command::new("curl")
271        .args(&[
272            "--progress-bar",
273            "--location",
274            "--fail",
275            "--output",
276            &temp_path,
277            url,
278        ])
279        .status()
280        .map_err(DownloadError::CurlExecutionFailed)?;
281
282    if !output.success() {
283        return Err(DownloadError::DownloadFailed(url.to_string()));
284    }
285
286    // Show file size after download
287    match fs::metadata(&temp_path) {
288        Ok(metadata) => {
289            let size_bytes = metadata.len();
290            let size_kb = size_bytes / 1024;
291            let size_mb = size_kb / 1024;
292            if size_mb > 1 {
293                println!(
294                    "Download complete! File size: {:.2} MB",
295                    size_bytes as f64 / (1024.0 * 1024.0)
296                );
297            } else {
298                println!(
299                    "Download complete! File size: {:.2} KB",
300                    size_bytes as f64 / 1024.0
301                );
302            }
303        }
304        Err(_) => println!("Download complete!"),
305    }
306
307    // Check file size if minimum size is specified
308    if min_size_kb > 0 {
309        let metadata = fs::metadata(&temp_path).map_err(DownloadError::FileMetadataError)?;
310        let size_kb = metadata.len() as i64 / 1024;
311        if size_kb < min_size_kb {
312            fs::remove_file(&temp_path).map_err(DownloadError::RemoveFileFailed)?;
313            return Err(DownloadError::FileTooSmall(size_kb, min_size_kb));
314        }
315    }
316
317    // Rename the temporary file to the final destination
318    fs::rename(&temp_path, dest).map_err(|e| DownloadError::CreateDirectoryFailed(e))?;
319
320    Ok(dest.to_string())
321}
322
323/**
324 * Make a file executable (equivalent to chmod +x).
325 *
326 * # Arguments
327 *
328 * * `path` - The path to the file to make executable
329 *
330 * # Returns
331 *
332 * * `Ok(String)` - A success message including the path
333 * * `Err(DownloadError)` - An error if the operation failed
334 *
335 * # Examples
336 *
337 * ```no_run
338 * use sal_os::chmod_exec;
339 *
340 * fn main() -> Result<(), Box<dyn std::error::Error>> {
341 *     // Make a file executable
342 *     chmod_exec("/path/to/file")?;
343 *     Ok(())
344 * }
345 * ```
346 */
347pub fn chmod_exec(path: &str) -> Result<String, DownloadError> {
348    let path_obj = Path::new(path);
349
350    // Check if the path exists and is a file
351    if !path_obj.exists() {
352        return Err(DownloadError::NotAFile(format!(
353            "Path does not exist: {}",
354            path
355        )));
356    }
357
358    if !path_obj.is_file() {
359        return Err(DownloadError::NotAFile(format!(
360            "Path is not a file: {}",
361            path
362        )));
363    }
364
365    // Get current permissions
366    let metadata = fs::metadata(path).map_err(DownloadError::FileMetadataError)?;
367    let mut permissions = metadata.permissions();
368
369    // Set executable bit for user, group, and others
370    #[cfg(unix)]
371    {
372        use std::os::unix::fs::PermissionsExt;
373        let mode = permissions.mode();
374        // Add executable bit for user, group, and others (equivalent to +x)
375        let new_mode = mode | 0o111;
376        permissions.set_mode(new_mode);
377    }
378
379    #[cfg(not(unix))]
380    {
381        // On non-Unix platforms, we can't set executable bit directly
382        // Just return success with a warning
383        return Ok(format!(
384            "Made {} executable (note: non-Unix platform, may not be fully supported)",
385            path
386        ));
387    }
388
389    // Apply the new permissions
390    fs::set_permissions(path, permissions).map_err(|e| {
391        DownloadError::CommandExecutionFailed(io::Error::new(
392            io::ErrorKind::Other,
393            format!("Failed to set executable permissions: {}", e),
394        ))
395    })?;
396
397    Ok(format!("Made {} executable", path))
398}
399
400/**
401 * Download a file and install it if it's a supported package format.
402 *
403 * # Arguments
404 *
405 * * `url` - The URL to download from
406 * * `min_size_kb` - Minimum required file size in KB (0 for no minimum)
407 *
408 * # Returns
409 *
410 * * `Ok(String)` - The path where the file was saved or extracted
411 * * `Err(DownloadError)` - An error if the download or installation failed
412 *
413 * # Examples
414 *
415 * ```no_run
416 * use sal_os::download_install;
417 *
418 * fn main() -> Result<(), Box<dyn std::error::Error>> {
419 *     // Download and install a .deb package
420 *     let result = download_install("https://example.com/package.deb", 100)?;
421 *     Ok(())
422 * }
423 * ```
424 *
425 * # Notes
426 *
427 * Currently only supports .deb packages on Debian-based systems.
428 * For other file types, it behaves the same as the download function.
429 */
430pub fn download_install(url: &str, min_size_kb: i64) -> Result<String, DownloadError> {
431    // Extract filename from URL
432    let filename = match url.split('/').last() {
433        Some(name) => name,
434        None => {
435            return Err(DownloadError::InvalidUrl(
436                "cannot extract filename".to_string(),
437            ))
438        }
439    };
440
441    // Create a proper destination path
442    let dest_path = format!("/tmp/{}", filename);
443
444    // Check if it's a compressed file that needs extraction
445    let lower_url = url.to_lowercase();
446    let is_archive = lower_url.ends_with(".tar.gz")
447        || lower_url.ends_with(".tgz")
448        || lower_url.ends_with(".tar")
449        || lower_url.ends_with(".zip");
450
451    let download_result = if is_archive {
452        // For archives, use the directory-based download function
453        download(url, "/tmp", min_size_kb)?
454    } else {
455        // For regular files, use the file-specific download function
456        download_file(url, &dest_path, min_size_kb)?
457    };
458
459    // Check if the downloaded result is a file
460    let path = Path::new(&dest_path);
461    if !path.is_file() {
462        return Ok(download_result); // Not a file, might be an extracted directory
463    }
464
465    // Check if it's a .deb package
466    if dest_path.to_lowercase().ends_with(".deb") {
467        // Check if we're on a Debian-based platform
468        let platform_check = Command::new("sh")
469            .arg("-c")
470            .arg("command -v dpkg > /dev/null && command -v apt > /dev/null || test -f /etc/debian_version")
471            .status();
472
473        match platform_check {
474            Ok(status) => {
475                if !status.success() {
476                    return Err(DownloadError::PlatformNotSupported(
477                        "Cannot install .deb package: not on a Debian-based system".to_string(),
478                    ));
479                }
480            }
481            Err(_) => {
482                return Err(DownloadError::PlatformNotSupported(
483                    "Failed to check system compatibility for .deb installation".to_string(),
484                ))
485            }
486        }
487
488        // Install the .deb package non-interactively
489        println!("Installing package: {}", dest_path);
490        let install_result = Command::new("sudo")
491            .args(&["dpkg", "--install", &dest_path])
492            .status();
493
494        match install_result {
495            Ok(status) => {
496                if !status.success() {
497                    // If dpkg fails, try to fix dependencies and retry
498                    println!("Attempting to resolve dependencies...");
499                    let fix_deps = Command::new("sudo")
500                        .args(&["apt-get", "install", "-f", "-y"])
501                        .status();
502
503                    if let Ok(fix_status) = fix_deps {
504                        if !fix_status.success() {
505                            return Err(DownloadError::InstallationFailed(
506                                "Failed to resolve package dependencies".to_string(),
507                            ));
508                        }
509                    } else {
510                        return Err(DownloadError::InstallationFailed(
511                            "Failed to resolve package dependencies".to_string(),
512                        ));
513                    }
514                }
515                println!("Package installation completed successfully");
516            }
517            Err(e) => return Err(DownloadError::CommandExecutionFailed(e)),
518        }
519    }
520
521    Ok(download_result)
522}