use std::fmt::Debug;
use std::sync::Arc;
use crate::models::Token;
use crate::{Result, error};
use async_trait::async_trait;
use bytes::Bytes;
use reqwest::header::LOCATION;
use reqwest::{RequestBuilder, Response};
use snafu::{OptionExt, ResultExt};
use url::Url;
pub(crate) const MANIFEST_ACCEPT: &str = concat!(
"application/vnd.oci.image.index.v1+json,",
"application/vnd.oci.image.manifest.v1+json,",
"application/vnd.docker.distribution.manifest.list.v2+json,",
"application/vnd.docker.distribution.manifest.v2+json",
);
#[async_trait]
pub(crate) trait RegistryClientImpl: Send + Sync + Debug {
async fn catalog(&self, uri: &Url) -> Result<Response>;
async fn get_tags(&self, uri: &Url, repository: &str) -> Result<Response>;
async fn head_blob(&self, uri: &Url, repository: &str, digest: &str) -> Result<Response>;
async fn get_blob(&self, uri: &Url, repository: &str, digest: &str) -> Result<Response>;
async fn del_blob(&self, uri: &Url, repository: &str, digest: &str) -> Result<Response>;
async fn post_blob(
&self,
uri: &Url,
repository: &str,
data: Bytes,
digest: &str,
) -> Result<Response>;
async fn start_upload(&self, uri: &Url, repository: &str) -> Result<Response>;
async fn upload_part(
&self,
upload_url: &Url,
data: Bytes,
start: usize,
end: usize,
) -> Result<Response>;
async fn finish_blob_upload(
&self,
upload_url: &Url,
data: Bytes,
digest: &str,
start: usize,
end: usize,
) -> Result<Response>;
async fn head_manifest(&self, uri: &Url, repository: &str, reference: &str)
-> Result<Response>;
async fn get_manifest(&self, uri: &Url, repository: &str, reference: &str) -> Result<Response>;
async fn put_manifest(
&self,
uri: &Url,
repository: &str,
reference: &str,
content_type: &str,
body: Bytes,
) -> Result<Response>;
async fn del_manifest(&self, uri: &Url, repository: &str, reference: &str) -> Result<Response>;
}
#[derive(Debug)]
pub struct SimpleRegistryClient {
client: reqwest::Client,
auth: Option<Token>,
}
impl SimpleRegistryClient {
pub fn new(auth: Option<Token>) -> Self {
Self {
client: reqwest::Client::new(),
auth,
}
}
pub(crate) fn auth(&self, request: RequestBuilder) -> RequestBuilder {
if let Some(token) = self.auth.as_ref() {
match token {
Token::Bearer(t) => request.bearer_auth(t),
Token::Basic { username, password } => request.basic_auth(username, Some(password)),
}
} else {
request
}
}
}
fn v2_url<'a>(base: &Url, segments: impl IntoIterator<Item = &'a str>) -> Result<Url> {
let mut url = base.join("/v2/").context(error::UrlSnafu)?;
{
let mut path = url
.path_segments_mut()
.map_err(|_| error::Error::Internal {
context: "registry url cannot be used as a base for path segments",
})?;
path.pop_if_empty();
path.extend(segments);
}
Ok(url)
}
#[async_trait]
impl RegistryClientImpl for SimpleRegistryClient {
async fn catalog(&self, uri: &Url) -> Result<Response> {
let request = self
.client
.get(uri.join("/v2/_catalog").context(error::UrlSnafu)?);
self.auth(request).send().await.context(error::RequestSnafu)
}
async fn head_blob(&self, uri: &Url, repository: &str, digest: &str) -> Result<Response> {
let request = self
.client
.head(v2_url(uri, repository.split('/').chain(["blobs", digest]))?);
self.auth(request).send().await.context(error::RequestSnafu)
}
async fn get_blob(&self, uri: &Url, repository: &str, digest: &str) -> Result<Response> {
let request = self
.client
.get(v2_url(uri, repository.split('/').chain(["blobs", digest]))?);
self.auth(request).send().await.context(error::RequestSnafu)
}
async fn del_blob(&self, uri: &Url, repository: &str, digest: &str) -> Result<Response> {
let request = self
.client
.delete(v2_url(uri, repository.split('/').chain(["blobs", digest]))?);
self.auth(request).send().await.context(error::RequestSnafu)
}
async fn get_tags(&self, uri: &Url, repository: &str) -> Result<Response> {
let request = self
.client
.get(v2_url(uri, repository.split('/').chain(["tags", "list"]))?);
self.auth(request).send().await.context(error::RequestSnafu)
}
async fn post_blob(
&self,
uri: &Url,
repository: &str,
data: Bytes,
digest: &str,
) -> Result<Response> {
let mut uri = v2_url(uri, repository.split('/').chain(["blobs", "uploads", ""]))?;
uri.query_pairs_mut().append_pair("digest", digest);
let request = self.client.post(uri);
self.auth(request)
.header("Content-Type", "application/octet-stream")
.header("Content-Length", data.len())
.body(data)
.send()
.await
.context(error::RequestSnafu)
}
async fn start_upload(&self, uri: &Url, repository: &str) -> Result<Response> {
let request = self.client.post(v2_url(
uri,
repository.split('/').chain(["blobs", "uploads", ""]),
)?);
self.auth(request)
.header("Content-Length", 0)
.send()
.await
.context(error::RequestSnafu)
}
async fn upload_part(
&self,
upload_url: &Url,
data: Bytes,
start: usize,
end: usize,
) -> Result<Response> {
let last = end.saturating_sub(1);
let request = self.client.patch(upload_url.clone());
self.auth(request)
.header("Content-Type", "application/octet-stream")
.header("Content-Length", data.len())
.header("Content-Range", format!("{}-{}", start, last))
.body(data)
.send()
.await
.context(error::RequestSnafu)
}
async fn finish_blob_upload(
&self,
upload_url: &Url,
data: Bytes,
digest: &str,
start: usize,
end: usize,
) -> Result<Response> {
let mut uri = upload_url.clone();
let new_query = match uri.query() {
Some(existing) if !existing.is_empty() => format!("{existing}&digest={digest}"),
_ => format!("digest={digest}"),
};
uri.set_query(Some(&new_query));
let request = self.client.put(uri);
let request = if data.is_empty() {
self.auth(request).header("Content-Length", 0)
} else {
let last = end.saturating_sub(1);
self.auth(request)
.header("Content-Type", "application/octet-stream")
.header("Content-Length", data.len())
.header("Content-Range", format!("{}-{}", start, last))
.body(data)
};
request.send().await.context(error::RequestSnafu)
}
async fn head_manifest(
&self,
uri: &Url,
repository: &str,
reference: &str,
) -> Result<Response> {
let request = self.client.head(v2_url(
uri,
repository.split('/').chain(["manifests", reference]),
)?);
self.auth(request)
.header("Accept", MANIFEST_ACCEPT)
.send()
.await
.context(error::RequestSnafu)
}
async fn get_manifest(&self, uri: &Url, repository: &str, reference: &str) -> Result<Response> {
let request = self.client.get(v2_url(
uri,
repository.split('/').chain(["manifests", reference]),
)?);
self.auth(request)
.header("Accept", MANIFEST_ACCEPT)
.send()
.await
.context(error::RequestSnafu)
}
async fn put_manifest(
&self,
uri: &Url,
repository: &str,
reference: &str,
content_type: &str,
body: Bytes,
) -> Result<Response> {
let request = self.client.put(v2_url(
uri,
repository.split('/').chain(["manifests", reference]),
)?);
self.auth(request)
.header("Content-Type", content_type)
.header("Content-Length", body.len())
.body(body)
.send()
.await
.context(error::RequestSnafu)
}
async fn del_manifest(&self, uri: &Url, repository: &str, reference: &str) -> Result<Response> {
let request = self.client.delete(v2_url(
uri,
repository.split('/').chain(["manifests", reference]),
)?);
self.auth(request).send().await.context(error::RequestSnafu)
}
}
#[derive(Clone, Debug)]
pub struct RegistryClient {
client: Arc<dyn RegistryClientImpl>,
}
impl RegistryClient {
pub fn new(auth: Option<Token>) -> Self {
Self {
client: Arc::new(SimpleRegistryClient::new(auth)),
}
}
pub async fn catalog(&self, uri: Url) -> Result<Response> {
self.client.catalog(&uri).await
}
pub async fn head_blob(
&self,
uri: Url,
repository: String,
digest: String,
) -> Result<Response> {
self.client
.head_blob(&uri, repository.as_str(), digest.as_str())
.await
}
pub async fn get_blob(&self, uri: Url, repository: String, digest: String) -> Result<Response> {
self.client
.get_blob(&uri, repository.as_str(), digest.as_str())
.await
}
pub async fn del_blob(&self, uri: Url, repository: String, digest: String) -> Result<Response> {
self.client
.del_blob(&uri, repository.as_str(), digest.as_str())
.await
}
pub async fn get_tags(&self, uri: &Url, repository: &str) -> Result<Response> {
self.client.get_tags(uri, repository).await
}
pub async fn post_blob(
&self,
uri: Url,
repository: String,
data: Bytes,
digest: String,
) -> Result<Response> {
self.client
.post_blob(&uri, repository.as_str(), data, digest.as_str())
.await
}
pub async fn start_upload(&self, uri: Url, repository: String) -> Result<Response> {
self.client.start_upload(&uri, repository.as_str()).await
}
pub async fn upload_part(
&self,
upload_url: Url,
data: Bytes,
start: usize,
end: usize,
) -> Result<Response> {
self.client.upload_part(&upload_url, data, start, end).await
}
pub async fn finish_blob_upload(
&self,
upload_url: Url,
data: Bytes,
digest: String,
start: usize,
end: usize,
) -> Result<Response> {
self.client
.finish_blob_upload(&upload_url, data, digest.as_str(), start, end)
.await
}
pub async fn head_manifest(
&self,
uri: Url,
repository: String,
reference: String,
) -> Result<Response> {
self.client
.head_manifest(&uri, repository.as_str(), reference.as_str())
.await
}
pub async fn get_manifest(
&self,
uri: Url,
repository: String,
reference: String,
) -> Result<Response> {
self.client
.get_manifest(&uri, repository.as_str(), reference.as_str())
.await
}
pub async fn put_manifest(
&self,
uri: Url,
repository: String,
reference: String,
content_type: &str,
body: Bytes,
) -> Result<Response> {
self.client
.put_manifest(
&uri,
repository.as_str(),
reference.as_str(),
content_type,
body,
)
.await
}
pub async fn del_manifest(
&self,
uri: Url,
repository: String,
reference: String,
) -> Result<Response> {
self.client
.del_manifest(&uri, repository.as_str(), reference.as_str())
.await
}
}
pub(crate) fn extract_location(response: &Response, base: &Url) -> crate::Result<Url> {
let header = response
.headers()
.get(LOCATION)
.context(error::StartBlobNoLocationSnafu)?
.to_str()
.context(error::ImproperHeaderSnafu)?;
base.join(header).context(error::UrlSnafu)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn v2_url_builds_expected_path() {
let base = Url::parse("https://registry.example.com").unwrap();
let url = v2_url(&base, "org/app".split('/').chain(["blobs", "sha256:abc"])).unwrap();
assert_eq!(
url.as_str(),
"https://registry.example.com/v2/org/app/blobs/sha256:abc"
);
}
#[test]
fn v2_url_percent_encodes_untrusted_segments() {
let base = Url::parse("https://registry.example.com").unwrap();
let malicious_digest = "sha256:../../../etc/passwd";
let url = v2_url(
&base,
"org/app".split('/').chain(["blobs", malicious_digest]),
)
.unwrap();
assert!(url.path().starts_with("/v2/org/app/blobs/"));
assert_eq!(url.path_segments().unwrap().count(), 5);
assert!(!url.path().contains("/etc/passwd"));
}
}