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

Commit baf4ea7

Browse files
committed
add support for download OCI packages
1 parent e946d33 commit baf4ea7

File tree

8 files changed

+308
-6
lines changed

8 files changed

+308
-6
lines changed

Cargo.lock

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

src/bin/soar-dl/cli.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ pub struct Args {
2121
#[arg(required = false, long)]
2222
pub gitlab: Vec<String>,
2323

24+
/// OCI reference
25+
#[arg(required = false, long)]
26+
pub ghcr: Vec<String>,
27+
2428
/// Links to files
2529
#[arg(required = false)]
2630
pub links: Vec<String>,

src/bin/soar-dl/download_manager.rs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ impl DownloadManager {
3131

3232
pub async fn execute(&self) {
3333
let _ = self.handle_github_downloads().await;
34+
let _ = self.handle_oci_downloads().await;
3435
let _ = self.handle_gitlab_downloads().await;
3536
let _ = self.handle_direct_downloads().await;
3637
}
@@ -120,6 +121,28 @@ impl DownloadManager {
120121
Ok(())
121122
}
122123

124+
async fn handle_oci_downloads(&self) -> Result<(), PlatformError> {
125+
if self.args.ghcr.is_empty() {
126+
return Ok(());
127+
}
128+
129+
let downloader = Downloader::default();
130+
for reference in &self.args.ghcr {
131+
println!("Downloading using OCI reference: {}", reference);
132+
133+
let options = DownloadOptions {
134+
url: reference.clone(),
135+
output_path: self.args.output.clone(),
136+
progress_callback: Some(self.progress_callback.clone()),
137+
};
138+
let _ = downloader
139+
.download_oci(options)
140+
.await
141+
.map_err(|e| eprintln!("{}", e));
142+
}
143+
Ok(())
144+
}
145+
123146
async fn handle_direct_downloads(&self) -> Result<(), DownloadError> {
124147
let downloader = Downloader::default();
125148
for link in &self.args.links {
@@ -161,6 +184,19 @@ impl DownloadManager {
161184
eprintln!("{}", e);
162185
}
163186
}
187+
Ok(PlatformUrl::Oci(url)) => {
188+
println!("Downloading using OCI reference: {}", url);
189+
190+
let options = DownloadOptions {
191+
url: link.clone(),
192+
output_path: self.args.output.clone(),
193+
progress_callback: Some(self.progress_callback.clone()),
194+
};
195+
let _ = downloader
196+
.download_oci(options)
197+
.await
198+
.map_err(|e| eprintln!("{}", e));
199+
}
164200
Err(err) => eprintln!("Error parsing URL '{}' : {}", link, err),
165201
};
166202
}

src/downloader.rs

Lines changed: 68 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,22 @@
1-
use std::{fs::Permissions, os::unix::fs::PermissionsExt, path::Path, sync::Arc};
1+
use std::{
2+
fs::Permissions,
3+
os::unix::fs::PermissionsExt,
4+
path::Path,
5+
sync::{Arc, Mutex},
6+
};
27

3-
use futures::StreamExt;
8+
use futures::{future::join_all, StreamExt};
49
use reqwest::header::USER_AGENT;
510
use tokio::{
611
fs::{self, OpenOptions},
712
io::AsyncWriteExt,
13+
task,
814
};
915
use url::Url;
1016

1117
use crate::{
1218
error::DownloadError,
19+
oci::{OciClient, Reference},
1320
utils::{extract_filename, is_elf},
1421
};
1522

@@ -120,4 +127,63 @@ impl Downloader {
120127

121128
Ok(filename)
122129
}
130+
131+
pub async fn download_oci(&self, options: DownloadOptions) -> Result<(), DownloadError> {
132+
let url = options.url.clone();
133+
let reference: Reference = url.into();
134+
let oci_client = OciClient::new(reference);
135+
136+
let manifest = oci_client.manifest().await.unwrap();
137+
138+
let mut tasks = Vec::new();
139+
let total_bytes: u64 = manifest.layers.iter().map(|layer| layer.size).sum();
140+
141+
if let Some(ref callback) = options.progress_callback {
142+
callback(DownloadState::Progress(DownloadProgress {
143+
bytes_downloaded: 0,
144+
total_bytes: Some(total_bytes),
145+
url: options.url.clone(),
146+
file_path: String::new(),
147+
}));
148+
}
149+
150+
let downloaded_bytes = Arc::new(Mutex::new(0u64));
151+
let outdir = options.output_path;
152+
153+
for layer in manifest.layers {
154+
let client_clone = oci_client.clone();
155+
let cb_clone = options.progress_callback.clone();
156+
let downloaded_bytes = downloaded_bytes.clone();
157+
let url = options.url.clone();
158+
let outdir = outdir.clone();
159+
160+
let task = task::spawn(async move {
161+
let chunk_size = client_clone
162+
.pull_layer(&layer, outdir, move |bytes| {
163+
if let Some(ref callback) = cb_clone {
164+
let mut current = downloaded_bytes.lock().unwrap();
165+
*current = bytes;
166+
callback(DownloadState::Progress(DownloadProgress {
167+
bytes_downloaded: *current,
168+
total_bytes: Some(total_bytes),
169+
url: url.clone(),
170+
file_path: String::new(),
171+
}));
172+
}
173+
})
174+
.await?;
175+
176+
Ok::<u64, DownloadError>(chunk_size)
177+
});
178+
tasks.push(task);
179+
}
180+
181+
let _ = join_all(tasks).await;
182+
183+
if let Some(ref callback) = options.progress_callback {
184+
callback(DownloadState::Complete);
185+
}
186+
187+
Ok(())
188+
}
123189
}

src/error.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ pub enum DownloadError {
1414
url: String,
1515
status: reqwest::StatusCode,
1616
},
17+
InvalidResponse,
1718
}
1819

1920
impl Display for DownloadError {
@@ -25,6 +26,7 @@ impl Display for DownloadError {
2526
DownloadError::ResourceError { url, status } => {
2627
write!(f, "Failed to fetch resource from {} [{}]", url, status)
2728
}
29+
DownloadError::InvalidResponse => write!(f, "Failed to parse response"),
2830
}
2931
}
3032
}
@@ -36,6 +38,7 @@ impl Error for DownloadError {
3638
DownloadError::InvalidUrl { source, .. } => Some(source),
3739
DownloadError::NetworkError { source } => Some(source),
3840
DownloadError::ResourceError { .. } => None,
41+
DownloadError::InvalidResponse => None,
3942
}
4043
}
4144
}

src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,6 @@ pub mod downloader;
22
pub mod error;
33
pub mod github;
44
pub mod gitlab;
5+
pub mod oci;
56
pub mod platform;
67
pub mod utils;

src/oci.rs

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
use std::{
2+
collections::HashMap,
3+
path::{Path, PathBuf},
4+
};
5+
6+
use futures::StreamExt;
7+
use reqwest::header::{self, HeaderMap};
8+
use serde::Deserialize;
9+
use tokio::{
10+
fs::{self, OpenOptions},
11+
io::AsyncWriteExt,
12+
};
13+
14+
use crate::error::DownloadError;
15+
16+
#[derive(Deserialize)]
17+
pub struct OciLayer {
18+
#[serde(rename = "mediaType")]
19+
pub media_type: String,
20+
pub digest: String,
21+
pub size: u64,
22+
pub annotations: HashMap<String, String>,
23+
}
24+
25+
#[derive(Deserialize)]
26+
pub struct OciConfig {
27+
#[serde(rename = "mediaType")]
28+
pub media_type: String,
29+
pub digest: String,
30+
pub size: u64,
31+
}
32+
33+
#[derive(Deserialize)]
34+
pub struct OciManifest {
35+
#[serde(rename = "mediaType")]
36+
pub media_type: String,
37+
pub config: OciConfig,
38+
pub layers: Vec<OciLayer>,
39+
}
40+
41+
#[derive(Clone)]
42+
pub struct OciClient {
43+
client: reqwest::Client,
44+
reference: Reference,
45+
}
46+
47+
#[derive(Clone)]
48+
pub struct Reference {
49+
package: String,
50+
tag: String,
51+
}
52+
53+
impl From<&str> for Reference {
54+
fn from(value: &str) -> Self {
55+
let paths = value.trim_start_matches("ghcr.io/");
56+
let (package, tag) = paths.split_once(':').unwrap_or((paths, "latest"));
57+
58+
Self {
59+
package: package.to_string(),
60+
tag: tag.to_string(),
61+
}
62+
}
63+
}
64+
65+
impl From<String> for Reference {
66+
fn from(value: String) -> Self {
67+
let paths = value.trim_start_matches("ghcr.io/");
68+
let (package, tag) = paths.split_once(':').unwrap_or((paths, "latest"));
69+
70+
Self {
71+
package: package.to_string(),
72+
tag: tag.to_string(),
73+
}
74+
}
75+
}
76+
77+
impl OciClient {
78+
pub fn new(reference: Reference) -> Self {
79+
let client = reqwest::Client::new();
80+
Self { client, reference }
81+
}
82+
83+
pub fn headers(&self) -> HeaderMap {
84+
let mut header_map = HeaderMap::new();
85+
header_map.insert(header::ACCEPT, "application/vnd.docker.distribution.manifest.v2+json, application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.oci.image.manifest.v1+json, application/vnd.oci.image.index.v1+json, application/vnd.oci.artifact.manifest.v1+json".parse().unwrap());
86+
header_map.insert(header::AUTHORIZATION, "Bearer QQ==".parse().unwrap());
87+
header_map
88+
}
89+
90+
pub async fn manifest(&self) -> Result<OciManifest, DownloadError> {
91+
let manifest_url = format!(
92+
"https://ghcr.io/v2/{}/manifests/{}",
93+
self.reference.package, self.reference.tag
94+
);
95+
let resp = self
96+
.client
97+
.get(&manifest_url)
98+
.headers(self.headers())
99+
.send()
100+
.await
101+
.map_err(|err| DownloadError::NetworkError { source: err })?;
102+
103+
if !resp.status().is_success() {
104+
return Err(DownloadError::ResourceError {
105+
status: resp.status(),
106+
url: manifest_url,
107+
});
108+
}
109+
110+
let manifest: OciManifest = resp
111+
.json()
112+
.await
113+
.map_err(|_| DownloadError::InvalidResponse)?;
114+
Ok(manifest)
115+
}
116+
117+
pub async fn pull_layer<F, P: AsRef<Path>>(
118+
&self,
119+
layer: &OciLayer,
120+
output_dir: Option<P>,
121+
progress_callback: F,
122+
) -> Result<u64, DownloadError>
123+
where
124+
F: Fn(u64) + Send + 'static,
125+
{
126+
let blob_url = format!(
127+
"https://ghcr.io/v2/{}/blobs/{}",
128+
self.reference.package, layer.digest
129+
);
130+
let resp = self
131+
.client
132+
.get(&blob_url)
133+
.headers(self.headers())
134+
.send()
135+
.await
136+
.map_err(|err| DownloadError::NetworkError { source: err })?;
137+
138+
if !resp.status().is_success() {
139+
return Err(DownloadError::ResourceError {
140+
status: resp.status(),
141+
url: blob_url,
142+
});
143+
}
144+
145+
let filename = layer.get_title().unwrap();
146+
let (temp_path, final_path) = if let Some(output_dir) = output_dir {
147+
let output_dir = output_dir.as_ref();
148+
fs::create_dir_all(output_dir).await?;
149+
let final_path = output_dir.join(format!("{filename}"));
150+
let temp_path = output_dir.join(format!("{filename}.part"));
151+
(temp_path, final_path)
152+
} else {
153+
let final_path = PathBuf::from(&filename);
154+
let temp_path = PathBuf::from(format!("{filename}.part"));
155+
(temp_path, final_path)
156+
};
157+
158+
let mut file = OpenOptions::new()
159+
.create(true)
160+
.append(true)
161+
.open(&temp_path)
162+
.await?;
163+
164+
let mut stream = resp.bytes_stream();
165+
let mut total_bytes_downloaded = 0;
166+
167+
while let Some(chunk) = stream.next().await {
168+
let chunk = chunk.unwrap();
169+
let chunk_size = chunk.len() as u64;
170+
file.write_all(&chunk).await.unwrap();
171+
172+
progress_callback(chunk_size);
173+
total_bytes_downloaded += chunk_size;
174+
}
175+
176+
fs::rename(&temp_path, &final_path).await?;
177+
178+
Ok(total_bytes_downloaded)
179+
}
180+
}
181+
182+
impl OciLayer {
183+
pub fn get_title(&self) -> Option<String> {
184+
self.annotations
185+
.get("org.opencontainers.image.title")
186+
.cloned()
187+
}
188+
}

0 commit comments

Comments
 (0)