Skip to content
This repository was archived by the owner on Jan 21, 2026. It is now read-only.

Commit a14ba9b

Browse files
committed
add support for archives
1 parent 33e1d91 commit a14ba9b

File tree

10 files changed

+689
-20
lines changed

10 files changed

+689
-20
lines changed

Cargo.lock

Lines changed: 490 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,21 @@ panic = "abort"
1919
binary = ["clap", "indicatif"]
2020

2121
[dependencies]
22+
bzip2 = "0.5.2"
2223
clap = { version = "4.5.31", features = ["cargo", "derive"], optional = true }
24+
flate2 = "1.1.0"
2325
futures = "0.3.31"
2426
indicatif = { version = "0.17.11", optional = true }
2527
regex = { version = "1.11.1", default-features = false, features = ["std", "unicode-case", "unicode-perl"] }
2628
reqwest = { version = "0.12.12", default-features = false, features = ["rustls-tls", "stream", "http2", "blocking", "json"] }
2729
serde = { version = "1.0.218", features = ["derive"] }
2830
serde_json = "1.0.139"
31+
tar = "0.4.44"
2932
tokio = { version = "1.43.0", features = ["macros", "rt-multi-thread"] }
3033
url = "2.5.4"
34+
xz2 = "0.1.7"
35+
zip = "2.2.3"
36+
zstd = "0.13.3"
3137

3238
[[bin]]
3339
name = "soar-dl"

src/archive.rs

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
use tokio::io::AsyncReadExt;
2+
use zip::result::ZipError;
3+
4+
use crate::error::DownloadError;
5+
use std::{
6+
io::{self, Read},
7+
path::Path,
8+
};
9+
10+
#[derive(Debug)]
11+
enum ArchiveFormat {
12+
Zip,
13+
Gz,
14+
Xz,
15+
Bz2,
16+
Zst,
17+
}
18+
19+
const ZIP_MAGIC_BYTES: [u8; 4] = [0x50, 0x4B, 0x03, 0x04];
20+
const GZIP_MAGIC_BYTES: [u8; 2] = [0x1F, 0x8B];
21+
const XZ_MAGIC_BYTES: [u8; 6] = [0xFD, 0x37, 0x7A, 0x58, 0x5A, 0x00];
22+
const BZIP2_MAGIC_BYTES: [u8; 3] = [0x42, 0x5A, 0x68];
23+
const ZSTD_MAGIC_BYTES: [u8; 4] = [0x28, 0xB5, 0x2F, 0xFD];
24+
25+
/// Extracts the contents of an archive file to a directory.
26+
///
27+
/// This function automatically detects the archive format based on file signatures,
28+
/// then extracts its contents to a directory named after the archive file.
29+
///
30+
/// # Arguments
31+
/// * `path` - Path to the archive file to be extracted
32+
/// * `output_dir` - Path where contents should be extracted
33+
///
34+
/// # Returns
35+
/// * `Ok(())` if extraction was successful
36+
/// * `Err(DownloadError)` if an error occurred during extraction
37+
pub async fn extract_archive<P: AsRef<Path>>(path: P, output_dir: P) -> Result<(), DownloadError> {
38+
let path = path.as_ref();
39+
let output_dir = output_dir.as_ref();
40+
let mut file = tokio::fs::File::open(path).await?;
41+
let mut magic = vec![0u8; 6];
42+
let n = file.read(&mut magic).await?;
43+
let magic = &magic[..n];
44+
45+
let Some(format) = detect_archive_format(magic) else {
46+
return Ok(());
47+
};
48+
49+
match format {
50+
ArchiveFormat::Zip => extract_zip(path, &output_dir)
51+
.await
52+
.map_err(|err| DownloadError::ZipError(err)),
53+
ArchiveFormat::Gz => extract_tar(path, &output_dir, flate2::read::GzDecoder::new).await,
54+
ArchiveFormat::Xz => extract_tar(path, &output_dir, xz2::read::XzDecoder::new).await,
55+
ArchiveFormat::Bz2 => extract_tar(path, &output_dir, bzip2::read::BzDecoder::new).await,
56+
ArchiveFormat::Zst => {
57+
extract_tar(path, &output_dir, |f| {
58+
zstd::stream::read::Decoder::new(f).unwrap()
59+
})
60+
.await
61+
}
62+
}
63+
}
64+
65+
/// Helper function to safely check if a byte slice starts with a pattern
66+
fn starts_with(data: &[u8], pattern: &[u8]) -> bool {
67+
data.len() >= pattern.len() && &data[..pattern.len()] == pattern
68+
}
69+
70+
/// Detects the archive format by examining the file's magic bytes (signature).
71+
///
72+
/// # Arguments
73+
/// * `magic` - Byte slice containing the beginning of the file (typically first 512 bytes)
74+
///
75+
/// # Returns
76+
/// * `Some(ArchiveFormat)` - The detected archive format
77+
/// * `None` - If the format could not be recognized
78+
fn detect_archive_format(magic: &[u8]) -> Option<ArchiveFormat> {
79+
if starts_with(magic, &ZIP_MAGIC_BYTES) {
80+
return Some(ArchiveFormat::Zip);
81+
}
82+
83+
if starts_with(magic, &GZIP_MAGIC_BYTES) {
84+
return Some(ArchiveFormat::Gz);
85+
}
86+
87+
if starts_with(magic, &XZ_MAGIC_BYTES) {
88+
return Some(ArchiveFormat::Xz);
89+
}
90+
91+
if starts_with(magic, &BZIP2_MAGIC_BYTES) {
92+
return Some(ArchiveFormat::Bz2);
93+
}
94+
95+
if starts_with(magic, &ZSTD_MAGIC_BYTES) {
96+
return Some(ArchiveFormat::Zst);
97+
}
98+
99+
None
100+
}
101+
102+
/// Generic function for extracting TAR-based archives with different compression formats.
103+
///
104+
/// This function handles the common extraction logic for all TAR-based formats by
105+
/// accepting a decompression function that converts the compressed stream to a
106+
/// readable stream.
107+
///
108+
/// # Arguments
109+
/// * `path` - Path to the archive file
110+
/// * `output_dir` - Path where contents should be extracted
111+
/// * `decompress` - Function that takes a file and returns a decompressed reader
112+
///
113+
/// # Returns
114+
/// * `Ok(())` if extraction was successful
115+
/// * `Err(DownloadError)` if an error occurred
116+
async fn extract_tar<F, R>(
117+
path: &Path,
118+
output_dir: &Path,
119+
decompress: F,
120+
) -> Result<(), DownloadError>
121+
where
122+
F: FnOnce(std::fs::File) -> R + Send + 'static,
123+
R: Read + Send + 'static,
124+
{
125+
let path = path.to_path_buf();
126+
let output_dir = output_dir.to_path_buf();
127+
128+
let file = std::fs::File::open(&path)?;
129+
let decompressed = decompress(file);
130+
let mut archive = tar::Archive::new(decompressed);
131+
archive.unpack(&output_dir)?;
132+
133+
Ok(())
134+
}
135+
136+
/// Extracts a ZIP archive to the specified output directory.
137+
///
138+
/// # Arguments
139+
/// * `path` - Path to the ZIP archive
140+
/// * `output_dir` - Directory where the contents should be extracted
141+
///
142+
/// # Returns
143+
/// * `Ok(())` if extraction was successful
144+
/// * `Err(DownloadError)` if an error occurred
145+
async fn extract_zip(path: &Path, output_dir: &Path) -> Result<(), ZipError> {
146+
let path = path.to_path_buf();
147+
let output_dir = output_dir.to_path_buf();
148+
149+
let file = std::fs::File::open(&path)?;
150+
let mut archive = zip::ZipArchive::new(file)?;
151+
152+
for i in 0..archive.len() {
153+
let mut file = archive.by_index(i)?;
154+
let out_path = output_dir.join(file.name());
155+
156+
if file.name().ends_with('/') {
157+
std::fs::create_dir_all(&out_path)?;
158+
} else {
159+
if let Some(p) = out_path.parent() {
160+
if !p.exists() {
161+
std::fs::create_dir_all(p)?;
162+
}
163+
}
164+
let mut out_file = std::fs::File::create(&out_path)?;
165+
io::copy(&mut file, &mut out_file)?;
166+
}
167+
}
168+
Ok(())
169+
}

src/bin/soar-dl/cli.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,4 +60,8 @@ pub struct Args {
6060
/// Whether to use exact case matching for keywords
6161
#[arg(required = false, long)]
6262
pub exact_case: bool,
63+
64+
/// Extract supported archive automatically
65+
#[arg(required = false, long)]
66+
pub extract: bool,
6367
}

src/bin/soar-dl/download_manager.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ impl DownloadManager {
6262
match_keywords: self.args.match_keywords.clone().unwrap_or_default(),
6363
exclude_keywords: self.args.exclude_keywords.clone().unwrap_or_default(),
6464
exact_case: false,
65+
extract_archive: self.args.extract,
6566
}
6667
}
6768

@@ -190,6 +191,7 @@ impl DownloadManager {
190191
url: link.clone(),
191192
output_path: self.args.output.clone(),
192193
progress_callback: Some(self.progress_callback.clone()),
194+
extract_archive: self.args.extract,
193195
};
194196
let _ = downloader
195197
.download(options)

src/bin/soar-dl/progress.rs

Lines changed: 6 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,12 @@ use soar_dl::downloader::DownloadState;
33

44
pub fn create_progress_bar() -> ProgressBar {
55
let progress_bar = ProgressBar::new(0);
6-
let style =
7-
ProgressStyle::with_template("[{wide_bar:.green/white}] {speed:14} {computed_bytes:22}")
8-
.unwrap()
9-
.with_key("computed_bytes", format_bytes)
10-
.with_key("speed", format_speed)
11-
.progress_chars("━━");
6+
let style = ProgressStyle::with_template(
7+
"[{wide_bar:.green/white}] {bytes_per_sec:14} {computed_bytes:22}",
8+
)
9+
.unwrap()
10+
.with_key("computed_bytes", format_bytes)
11+
.progress_chars("━━");
1212
progress_bar.set_style(style);
1313
progress_bar
1414
}
@@ -23,19 +23,6 @@ fn format_bytes(state: &ProgressState, w: &mut dyn std::fmt::Write) {
2323
.unwrap();
2424
}
2525

26-
fn format_speed(state: &ProgressState, w: &mut dyn std::fmt::Write) {
27-
let speed = calculate_speed(state.pos(), state.elapsed().as_secs_f64());
28-
write!(w, "{}/s", HumanBytes(speed)).unwrap();
29-
}
30-
31-
fn calculate_speed(pos: u64, elapsed: f64) -> u64 {
32-
if elapsed > 0.0 {
33-
(pos as f64 / elapsed) as u64
34-
} else {
35-
0
36-
}
37-
}
38-
3926
pub fn handle_progress(state: DownloadState, progress_bar: &ProgressBar) {
4027
match state {
4128
DownloadState::Preparing(total_size) => {

src/downloader.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ use tokio::{
1818
use url::Url;
1919

2020
use crate::{
21+
archive,
2122
error::DownloadError,
2223
oci::{OciClient, OciLayer, OciManifest, Reference},
2324
utils::{extract_filename, extract_filename_from_url, is_elf, matches_pattern},
@@ -37,6 +38,7 @@ pub struct DownloadOptions {
3738
pub url: String,
3839
pub output_path: Option<String>,
3940
pub progress_callback: Option<Arc<dyn Fn(DownloadState) + Send + Sync + 'static>>,
41+
pub extract_archive: bool,
4042
}
4143

4244
#[derive(Default)]
@@ -148,6 +150,10 @@ impl Downloader {
148150
callback(DownloadState::Complete);
149151
}
150152

153+
if options.extract_archive {
154+
archive::extract_archive(output_path, Path::new(".")).await?;
155+
}
156+
151157
Ok(filename)
152158
}
153159
}

src/error.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ pub enum DownloadError {
1818
LayersNotFound,
1919
ChunkError,
2020
FileNameNotFound,
21+
ZipError(zip::result::ZipError),
2122
}
2223

2324
impl Display for DownloadError {
@@ -38,6 +39,7 @@ impl Display for DownloadError {
3839
"Couldn't find filename. Please provide filename explicitly."
3940
)
4041
}
42+
DownloadError::ZipError(err) => write!(f, "Zip error: {}", err),
4143
}
4244
}
4345
}

src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
pub mod archive;
12
pub mod downloader;
23
pub mod error;
34
pub mod github;

src/platform.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ impl PlatformUrl {
6363
let project = caps.get(1).unwrap().as_str();
6464
// if it's API url or contains `/-/` in path, ignore it
6565
if project.starts_with("api") || project.contains("/-/") {
66-
return Ok(PlatformUrl::DirectUrl(url.to_string()))
66+
return Ok(PlatformUrl::DirectUrl(url.to_string()));
6767
}
6868
let tag = caps
6969
.get(2)
@@ -120,6 +120,7 @@ pub struct PlatformDownloadOptions {
120120
pub match_keywords: Vec<String>,
121121
pub exclude_keywords: Vec<String>,
122122
pub exact_case: bool,
123+
pub extract_archive: bool,
123124
}
124125

125126
#[derive(Default)]
@@ -281,6 +282,7 @@ impl<P: ReleasePlatform> ReleaseHandler<P> {
281282
url: asset.download_url().to_string(),
282283
output_path: options.output_path,
283284
progress_callback: options.progress_callback,
285+
extract_archive: options.extract_archive,
284286
})
285287
.await?)
286288
}

0 commit comments

Comments
 (0)