use std::{
collections::HashMap,
fmt::{Display, Formatter},
};
use http::{
header::{ACCEPT, AUTHORIZATION},
Extensions,
};
use reqwest::{Request, Response};
use reqwest_middleware::{Middleware, Next};
use serde::Deserialize;
use url::{ParseError, Url};
use crate::mirror_middleware::create_404_response;
#[derive(thiserror::Error, Debug)]
enum OciMiddlewareError {
#[error("Reqwest error: {0}")]
Reqwest(#[from] reqwest::Error),
#[error("URL parse error: {0}")]
ParseError(#[from] ParseError),
#[error("Invalid header value: {0}")]
InvalidHeaderValue(#[from] reqwest::header::InvalidHeaderValue),
#[error("Layer not found")]
LayerNotFound,
}
#[derive(Default, Debug, Clone)]
pub struct OciMiddleware;
pub enum OciAction {
Pull,
Push,
PushPull,
}
#[derive(Clone, Debug, Deserialize)]
struct OCIToken {
token: String,
}
impl Display for OciAction {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
OciAction::Pull => write!(f, "pull"),
OciAction::Push => write!(f, "push"),
OciAction::PushPull => write!(f, "push,pull"),
}
}
}
async fn get_token(url: &OCIUrl, action: OciAction) -> Result<String, OciMiddlewareError> {
let token_url = url.token_url(action)?;
let response = reqwest::get(token_url.clone()).await?;
match response.error_for_status() {
Ok(response) => {
let token = response.json::<OCIToken>().await?;
Ok(token.token)
}
Err(e) => {
tracing::error!("OCI Mirror: failed to get token with URL: {}", token_url);
Err(OciMiddlewareError::Reqwest(e))
}
}
}
#[derive(Debug)]
struct OCIUrl {
url: Url,
host: String,
path: String,
tag: String,
media_type: String,
}
#[allow(dead_code)]
fn reverse_version_build_tag(tag: &str) -> String {
tag.replace("__p__", "+")
.replace("__e__", "!")
.replace("__eq__", "=")
}
fn version_build_tag(tag: &str) -> String {
tag.replace('+', "__p__")
.replace('!', "__e__")
.replace('=', "__eq__")
}
impl OCIUrl {
pub fn manifest_url(&self) -> Result<Url, ParseError> {
format!(
"https://{}/v2/{}/manifests/{}",
self.host, self.path, self.tag
)
.parse()
}
pub fn token_url(&self, action: OciAction) -> Result<Url, ParseError> {
format!(
"https://{}/token?scope=repository:{}:{}",
self.host, self.path, action
)
.parse()
}
pub fn blob_url(&self, sha256: &str) -> Result<Url, ParseError> {
format!("https://{}/v2/{}/blobs/{}", self.host, self.path, sha256).parse()
}
pub fn new(url: &Url) -> Result<Self, ParseError> {
let filename = url.path_segments().unwrap().next_back().unwrap();
let mut res = OCIUrl {
url: url.clone(),
tag: "latest".to_string(),
media_type: "".to_string(),
host: url.host_str().unwrap_or("").to_string(),
path: url.path().trim_start_matches('/').to_string(),
};
let mut computed_filename = filename.to_string();
if let Some(archive_name) = filename.strip_suffix(".conda") {
let parts = archive_name.rsplitn(3, '-').collect::<Vec<&str>>();
computed_filename = parts[2].to_string();
res.tag = version_build_tag(&format!("{}-{}", parts[1], parts[0]));
res.media_type = "application/vnd.conda.package.v2".to_string();
} else if let Some(archive_name) = filename.strip_suffix(".tar.bz2") {
let parts = archive_name.rsplitn(3, '-').collect::<Vec<&str>>();
computed_filename = parts[2].to_string();
res.tag = version_build_tag(&format!("{}-{}", parts[1], parts[0]));
res.media_type = "application/vnd.conda.package.v1".to_string();
} else if filename.starts_with("repodata.json") {
computed_filename = "repodata.json".to_string();
if filename == "repodata.json" {
res.media_type = "application/vnd.conda.repodata.v1+json".to_string();
} else if filename.ends_with(".gz") {
res.media_type = "application/vnd.conda.repodata.v1+json+gzip".to_string();
} else if filename.ends_with(".bz2") {
res.media_type = "application/vnd.conda.repodata.v1+json+bz2".to_string();
} else if filename.ends_with(".zst") {
res.media_type = "application/vnd.conda.repodata.v1+json+zst".to_string();
} else if filename.ends_with(".jlap") {
res.media_type = "application/vnd.conda.jlap.v1".to_string();
}
}
if computed_filename.starts_with('_') {
computed_filename = format!("zzz{computed_filename}");
}
res.url = url.join(&computed_filename).unwrap();
res.path = res.url.path().trim_start_matches('/').to_string();
Ok(res)
}
pub async fn get_blob_url(req: &mut Request) -> Result<(), OciMiddlewareError> {
let oci_url = OCIUrl::new(req.url())?;
let token = get_token(&oci_url, OciAction::Pull).await?;
let mut header = reqwest::header::HeaderValue::from_str(&format!("Bearer {token}"))?;
header.set_sensitive(true);
req.headers_mut().insert(AUTHORIZATION, header);
if let Some(expected_sha_hash) = req
.headers()
.get("X-Expected-Sha256")
.and_then(|s| s.to_str().ok())
{
*req.url_mut() = oci_url.blob_url(&format!("sha256:{expected_sha_hash}"))?;
} else {
let manifest_url = oci_url.manifest_url()?;
let manifest = reqwest::Client::new()
.get(manifest_url)
.bearer_auth(&token)
.header(ACCEPT, "application/vnd.oci.image.manifest.v1+json")
.send()
.await?;
let manifest: Manifest = manifest.json().await?;
let layer = if let Some(layer) = manifest
.layers
.iter()
.find(|l| l.media_type == oci_url.media_type)
{
layer
} else {
return Err(OciMiddlewareError::LayerNotFound);
};
*req.url_mut() = oci_url.blob_url(&layer.digest)?;
}
Ok(())
}
}
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
struct Layer {
digest: String,
#[serde(rename = "mediaType")]
media_type: String,
size: u64,
annotations: Option<HashMap<String, String>>,
}
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Manifest {
schema_version: u64,
layers: Vec<Layer>,
config: Layer,
annotations: Option<HashMap<String, String>>,
}
#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
impl Middleware for OciMiddleware {
async fn handle(
&self,
mut req: Request,
extensions: &mut Extensions,
next: Next<'_>,
) -> reqwest_middleware::Result<Response> {
if req.url().scheme() != "oci" {
return next.run(req, extensions).await;
}
if req.url().path().ends_with(".jlap") || req.url().path().ends_with(".json.bz2") {
return Ok(create_404_response(
req.url(),
"Mirror does not support this file type",
));
}
let res = OCIUrl::get_blob_url(&mut req).await;
match res {
Ok(_) => next.run(req, extensions).await,
Err(e) => match e {
OciMiddlewareError::LayerNotFound => {
return Ok(create_404_response(
req.url(),
"No layer available for media type",
));
}
_ => {
return Err(reqwest_middleware::Error::Middleware(e.into()));
}
},
}
}
}
#[cfg(test)]
mod tests {
use sha2::{Digest, Sha256};
use crate::OciMiddleware;
#[cfg(any(feature = "rustls-tls", feature = "native-tls"))]
#[tokio::test]
async fn test_oci_middleware() {
let middleware = OciMiddleware;
let client = reqwest::Client::new();
let client_with_middleware = reqwest_middleware::ClientBuilder::new(client)
.with(middleware)
.build();
let response = client_with_middleware
.get("oci://ghcr.io/channel-mirrors/conda-forge/osx-arm64/xtensor-0.25.0-h2ffa867_0.conda")
.header(
"X-Expected-Sha256",
"8485a64911c7011c0270b8266ab2bffa1da41c59ac4f0a48000c31d4f4a966dd",
)
.send()
.await
.unwrap();
assert_eq!(response.status(), 200);
let mut hasher = Sha256::new();
std::io::copy(&mut response.bytes().await.unwrap().as_ref(), &mut hasher).unwrap();
let hash = hasher.finalize();
assert_eq!(
format!("{hash:x}"),
"8485a64911c7011c0270b8266ab2bffa1da41c59ac4f0a48000c31d4f4a966dd"
);
}
#[cfg(any(feature = "rustls-tls", feature = "native-tls"))]
#[tokio::test]
async fn test_oci_middleware_repodata() {
let middleware = OciMiddleware;
let client = reqwest::Client::new();
let client_with_middleware = reqwest_middleware::ClientBuilder::new(client)
.with(middleware)
.build();
let response = client_with_middleware
.head("oci://ghcr.io/channel-mirrors/conda-forge/osx-arm64/repodata.json")
.send()
.await
.unwrap();
assert_eq!(response.status(), 200);
}
}