use futures::StreamExt;
use reqwest::{header, Client, StatusCode};
use serde::Deserialize;
use std::sync::Arc;
use tokio::sync::RwLock;
use tracing::{debug, instrument, trace};
use crate::error::{ImageError, Result};
use crate::manifest::{ImageManifest, ManifestList};
use crate::ImageRef;
const DOCKER_REGISTRY_URL: &str = "https://registry-1.docker.io";
const ACCEPT_MANIFEST: &str = concat!(
"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"
);
#[derive(Debug, Clone)]
pub struct RegistryAuth {
pub username: String,
pub password: String,
}
#[derive(Debug, Deserialize)]
struct TokenResponse {
token: String,
#[allow(dead_code)]
expires_in: Option<u64>,
}
#[derive(Debug, Default)]
struct AuthChallenge {
realm: String,
service: String,
scope: String,
}
#[derive(Debug, Clone)]
pub enum ManifestResponse {
Manifest(ImageManifest),
ManifestList(ManifestList),
}
pub struct RegistryClient {
client: Client,
registry_url: String,
auth: Option<RegistryAuth>,
tokens: Arc<RwLock<std::collections::HashMap<String, String>>>,
}
const DEFAULT_TIMEOUT_SECS: u64 = 300;
const DEFAULT_CONNECT_TIMEOUT_SECS: u64 = 30;
impl RegistryClient {
#[must_use]
pub fn new(registry: impl Into<String>) -> Self {
let registry = registry.into();
let registry_url = Self::registry_to_url(®istry);
let client = Client::builder()
.user_agent("arcbox/0.1")
.timeout(std::time::Duration::from_secs(DEFAULT_TIMEOUT_SECS))
.connect_timeout(std::time::Duration::from_secs(DEFAULT_CONNECT_TIMEOUT_SECS))
.pool_max_idle_per_host(4)
.build()
.expect("failed to create HTTP client");
Self {
client,
registry_url,
auth: None,
tokens: Arc::new(RwLock::new(std::collections::HashMap::new())),
}
}
#[must_use]
pub fn with_auth(mut self, auth: RegistryAuth) -> Self {
self.auth = Some(auth);
self
}
#[must_use]
pub fn registry(&self) -> &str {
&self.registry_url
}
fn registry_to_url(registry: &str) -> String {
match registry {
"docker.io" => DOCKER_REGISTRY_URL.to_string(),
r if r.starts_with("http://") || r.starts_with("https://") => r.to_string(),
r => format!("https://{r}"),
}
}
#[instrument(skip(self))]
pub async fn exists(&self, reference: &ImageRef) -> Result<bool> {
let url = format!(
"{}/v2/{}/manifests/{}",
self.registry_url, reference.repository, reference.reference
);
let response = self
.request_with_auth(reqwest::Method::HEAD, &url, &reference.repository)
.await?;
Ok(response.status().is_success())
}
#[instrument(skip(self))]
pub async fn get_manifest(&self, reference: &ImageRef) -> Result<ManifestResponse> {
let url = format!(
"{}/v2/{}/manifests/{}",
self.registry_url, reference.repository, reference.reference
);
debug!(url = %url, "fetching manifest");
let response = self
.request_with_auth(reqwest::Method::GET, &url, &reference.repository)
.await?;
let status = response.status();
if status == StatusCode::NOT_FOUND {
return Err(ImageError::NotFound(reference.full_name()));
}
if !status.is_success() {
let body = response.text().await.unwrap_or_default();
return Err(ImageError::Registry(format!(
"failed to fetch manifest: {status} - {body}"
)));
}
let content_type = response
.headers()
.get(header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string();
let body = response.bytes().await.map_err(|e| {
ImageError::Registry(format!("failed to read manifest body: {e}"))
})?;
trace!(content_type = %content_type, body_len = body.len(), "received manifest");
if content_type.contains("manifest.list") || content_type.contains("image.index") {
let list: ManifestList = serde_json::from_slice(&body)?;
Ok(ManifestResponse::ManifestList(list))
} else {
let manifest: ImageManifest = serde_json::from_slice(&body)?;
Ok(ManifestResponse::Manifest(manifest))
}
}
#[instrument(skip(self))]
pub async fn get_manifest_by_digest(
&self,
repository: &str,
digest: &str,
) -> Result<ImageManifest> {
let url = format!("{}/v2/{}/manifests/{}", self.registry_url, repository, digest);
debug!(url = %url, "fetching manifest by digest");
let response = self
.request_with_auth(reqwest::Method::GET, &url, repository)
.await?;
let status = response.status();
if !status.is_success() {
let body = response.text().await.unwrap_or_default();
return Err(ImageError::Registry(format!(
"failed to fetch manifest: {status} - {body}"
)));
}
let body = response.bytes().await.map_err(|e| {
ImageError::Registry(format!("failed to read manifest body: {e}"))
})?;
let manifest: ImageManifest = serde_json::from_slice(&body)?;
Ok(manifest)
}
#[instrument(skip(self))]
pub async fn get_blob(&self, reference: &ImageRef, digest: &str) -> Result<Vec<u8>> {
let url = format!(
"{}/v2/{}/blobs/{}",
self.registry_url, reference.repository, digest
);
debug!(url = %url, "fetching blob");
let response = self
.request_with_auth(reqwest::Method::GET, &url, &reference.repository)
.await?;
let status = response.status();
if status == StatusCode::NOT_FOUND {
return Err(ImageError::NotFound(format!("blob {digest}")));
}
if !status.is_success() {
let body = response.text().await.unwrap_or_default();
return Err(ImageError::Registry(format!(
"failed to fetch blob: {status} - {body}"
)));
}
let bytes = response.bytes().await.map_err(|e| {
ImageError::Registry(format!("failed to read blob body: {e}"))
})?;
Ok(bytes.to_vec())
}
#[instrument(skip(self, progress))]
pub async fn get_blob_with_progress<F>(
&self,
reference: &ImageRef,
digest: &str,
expected_size: u64,
mut progress: F,
) -> Result<Vec<u8>>
where
F: FnMut(u64, u64),
{
let url = format!(
"{}/v2/{}/blobs/{}",
self.registry_url, reference.repository, digest
);
debug!(url = %url, expected_size = expected_size, "fetching blob with progress");
let response = self
.request_with_auth(reqwest::Method::GET, &url, &reference.repository)
.await?;
let status = response.status();
if status == StatusCode::NOT_FOUND {
return Err(ImageError::NotFound(format!("blob {digest}")));
}
if !status.is_success() {
let body = response.text().await.unwrap_or_default();
return Err(ImageError::Registry(format!(
"failed to fetch blob: {status} - {body}"
)));
}
let mut stream = response.bytes_stream();
let capacity = usize::try_from(expected_size).unwrap_or(usize::MAX).min(256 * 1024 * 1024);
let mut data = Vec::with_capacity(capacity);
let mut downloaded: u64 = 0;
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|e| {
ImageError::Registry(format!("failed to read blob chunk: {e}"))
})?;
downloaded += chunk.len() as u64;
data.extend_from_slice(&chunk);
progress(downloaded, expected_size);
}
Ok(data)
}
#[instrument(skip(self, progress))]
pub async fn get_blob_by_repo<F>(
&self,
repository: &str,
digest: &str,
expected_size: u64,
progress: Option<F>,
) -> Result<Vec<u8>>
where
F: FnMut(u64, u64),
{
let url = format!(
"{}/v2/{}/blobs/{}",
self.registry_url, repository, digest
);
debug!(url = %url, expected_size = expected_size, "fetching blob by repo");
let response = self
.request_with_auth(reqwest::Method::GET, &url, repository)
.await?;
let status = response.status();
if status == StatusCode::NOT_FOUND {
return Err(ImageError::NotFound(format!("blob {digest}")));
}
if !status.is_success() {
let body = response.text().await.unwrap_or_default();
return Err(ImageError::Registry(format!(
"failed to fetch blob: {status} - {body}"
)));
}
let mut stream = response.bytes_stream();
let capacity = usize::try_from(expected_size).unwrap_or(usize::MAX).min(256 * 1024 * 1024);
let mut data = Vec::with_capacity(capacity);
let mut downloaded: u64 = 0;
if let Some(mut progress_fn) = progress {
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|e| {
ImageError::Registry(format!("failed to read blob chunk: {e}"))
})?;
downloaded += chunk.len() as u64;
data.extend_from_slice(&chunk);
progress_fn(downloaded, expected_size);
}
} else {
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|e| {
ImageError::Registry(format!("failed to read blob chunk: {e}"))
})?;
data.extend_from_slice(&chunk);
}
}
Ok(data)
}
async fn request_with_auth(
&self,
method: reqwest::Method,
url: &str,
repository: &str,
) -> Result<reqwest::Response> {
let cached_token = {
let tokens = self.tokens.read().await;
tokens.get(repository).cloned()
};
if let Some(token) = cached_token {
let response = self
.client
.request(method.clone(), url)
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.header(header::ACCEPT, ACCEPT_MANIFEST)
.send()
.await
.map_err(|e| ImageError::Registry(format!("request failed: {e}")))?;
if response.status() != StatusCode::UNAUTHORIZED {
return Ok(response);
}
let mut tokens = self.tokens.write().await;
tokens.remove(repository);
}
let response = self
.client
.request(method.clone(), url)
.header(header::ACCEPT, ACCEPT_MANIFEST)
.send()
.await
.map_err(|e| ImageError::Registry(format!("request failed: {e}")))?;
if response.status() == StatusCode::UNAUTHORIZED {
let challenge = Self::parse_www_authenticate(&response)?;
let token = self.get_token(&challenge).await?;
{
let mut tokens = self.tokens.write().await;
tokens.insert(repository.to_string(), token.clone());
}
let response = self
.client
.request(method, url)
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.header(header::ACCEPT, ACCEPT_MANIFEST)
.send()
.await
.map_err(|e| ImageError::Registry(format!("request failed: {e}")))?;
return Ok(response);
}
Ok(response)
}
fn parse_www_authenticate(response: &reqwest::Response) -> Result<AuthChallenge> {
let header = response
.headers()
.get(header::WWW_AUTHENTICATE)
.and_then(|v| v.to_str().ok())
.ok_or_else(|| {
ImageError::Auth("missing WWW-Authenticate header".to_string())
})?;
trace!(header = %header, "parsing WWW-Authenticate");
let mut challenge = AuthChallenge::default();
for part in header.trim_start_matches("Bearer ").split(',') {
let part = part.trim();
if let Some(value) = part.strip_prefix("realm=") {
challenge.realm = value.trim_matches('"').to_string();
} else if let Some(value) = part.strip_prefix("service=") {
challenge.service = value.trim_matches('"').to_string();
} else if let Some(value) = part.strip_prefix("scope=") {
challenge.scope = value.trim_matches('"').to_string();
}
}
if challenge.realm.is_empty() {
return Err(ImageError::Auth("invalid WWW-Authenticate header".to_string()));
}
Ok(challenge)
}
async fn get_token(&self, challenge: &AuthChallenge) -> Result<String> {
use std::fmt::Write;
let mut url = format!(
"{}?service={}&scope={}",
challenge.realm, challenge.service, challenge.scope
);
if let Some(auth) = &self.auth {
let _ = write!(url, "&account={}", urlencoding::encode(&auth.username));
}
debug!(url = %url, "requesting token");
let mut request = self.client.get(&url);
if let Some(auth) = &self.auth {
request = request.basic_auth(&auth.username, Some(&auth.password));
}
let response = request
.send()
.await
.map_err(|e| ImageError::Auth(format!("token request failed: {e}")))?;
if !response.status().is_success() {
let body = response.text().await.unwrap_or_default();
return Err(ImageError::Auth(format!("token request failed: {body}")));
}
let token_response: TokenResponse = response
.json()
.await
.map_err(|e| ImageError::Auth(format!("failed to parse token response: {e}")))?;
Ok(token_response.token)
}
}
#[must_use]
pub fn select_platform_manifest(list: &ManifestList) -> Option<&crate::manifest::PlatformManifest> {
let arch = current_arch();
list.manifests.iter().find(|m| {
m.platform.os == "linux" && m.platform.architecture == arch
})
}
fn current_arch() -> &'static str {
match std::env::consts::ARCH {
"x86_64" => "amd64",
"aarch64" => "arm64",
a => a,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_registry_to_url() {
assert_eq!(
RegistryClient::registry_to_url("docker.io"),
"https://registry-1.docker.io"
);
assert_eq!(
RegistryClient::registry_to_url("ghcr.io"),
"https://ghcr.io"
);
assert_eq!(
RegistryClient::registry_to_url("http://localhost:5000"),
"http://localhost:5000"
);
}
#[test]
fn test_current_arch() {
let arch = current_arch();
assert!(!arch.is_empty());
assert!(["amd64", "arm64", "x86_64", "aarch64"].contains(&arch));
}
#[test]
fn test_image_ref_parse() {
let r = ImageRef::parse("alpine").unwrap();
assert_eq!(r.registry, "docker.io");
assert_eq!(r.repository, "library/alpine");
assert_eq!(r.reference, "latest");
let r = ImageRef::parse("nginx:1.25").unwrap();
assert_eq!(r.registry, "docker.io");
assert_eq!(r.repository, "library/nginx");
assert_eq!(r.reference, "1.25");
let r = ImageRef::parse("ghcr.io/owner/repo:v1").unwrap();
assert_eq!(r.registry, "ghcr.io");
assert_eq!(r.repository, "owner/repo");
assert_eq!(r.reference, "v1");
}
}