1use reqwest::{
2 Client as HTTPClient,
3 header::HeaderMap,
4};
5use serde::{
6 Deserialize,
7 Serialize,
8};
9use tracing::{
10 Instrument,
11 info_span,
12};
13use url::Url;
14
15use crate::{
16 Image,
17 Manifest,
18 Registry,
19};
20
21mod error;
22pub mod token;
23pub mod token_cache;
24
25pub use error::Error;
26use token::Token;
27use token_cache::Cache as TokenCache;
28
29#[derive(Debug, Clone)]
30pub struct Client {
31 client: HTTPClient,
32 token_cache: Box<dyn TokenCache + Send>,
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct Response {
37 pub digest: Option<String>,
38 pub manifest: Manifest,
39}
40
41impl Default for Client {
42 fn default() -> Self {
43 Self {
44 client: HTTPClient::new(),
45 token_cache: Box::new(token_cache::MemoryTokenCache::default()),
46 }
47 }
48}
49
50impl Client {
51 #[must_use]
52 pub fn new() -> Self {
53 Self::default()
54 }
55
56 pub fn set_cache_memory(&mut self) {
57 self.token_cache = Box::new(token_cache::MemoryTokenCache::default());
58 }
59
60 pub fn disable_caching(&mut self) {
61 self.token_cache = Box::new(token_cache::NoCache);
62 }
63
64 #[cfg(feature = "redis_cache")]
65 pub fn set_cache_redis(&mut self, redis_client: redis::Client) {
66 self.token_cache = Box::new(token_cache::RedisCache::new(redis_client));
67 }
68
69 #[tracing::instrument]
77 pub async fn get_manifest_url(&self, url: &Url, image: &Image) -> Result<Response, Error> {
78 let mut headers = self.get_headers(image).await?;
79
80 let accept_header = [
81 "application/vnd.docker.container.image.v1+json",
82 "application/vnd.docker.distribution.manifest.list.v2+json",
83 "application/vnd.docker.distribution.manifest.v2+json",
84 "application/vnd.docker.image.rootfs.diff.tar.gzip",
85 "application/vnd.docker.image.rootfs.foreign.diff.tar.gzip",
86 "application/vnd.docker.plugin.v1+json",
87 "application/vnd.oci.image.index.v1+json",
88 "application/vnd.oci.image.manifest.v1+json",
89 ]
90 .join(", ");
91
92 headers.insert(
93 "Accept",
94 accept_header
95 .parse()
96 .map_err(Error::ParseManifestAcceptHeader)?,
97 );
98
99 let response = self
100 .client
101 .get(url.as_str())
102 .headers(headers)
103 .send()
104 .instrument(info_span!("get manifest request"))
105 .await
106 .map_err(Error::GetManifest)?;
107
108 let status = response.status();
109
110 let digest = response
111 .headers()
112 .get("Docker-Content-Digest")
113 .map(|header| {
114 header
115 .to_str()
116 .map(String::from)
117 .map_err(Error::ParseDockerContentDigestHeader)
118 })
119 .transpose()?;
120
121 let body = response
122 .text()
123 .instrument(info_span!("extract manifest request body"))
124 .await
125 .map_err(Error::ExtractManifestBody)?;
126
127 if !status.is_success() {
128 if status == reqwest::StatusCode::NOT_FOUND {
129 return Err(Error::ManifestNotFound(url.clone()));
130 }
131
132 return Err(Error::FailedManifestRequest(status, body));
133 }
134
135 let manifest =
136 serde_json::from_str(&body).map_err(|e| Error::DeserializeManifestBody(e, body))?;
137
138 Ok(Response { digest, manifest })
139 }
140
141 #[tracing::instrument(skip_all)]
147 pub async fn get_manifest(&self, image: &Image) -> Result<Response, Error> {
148 let registry_domain = image.registry.registry_domain();
149
150 let url = Url::parse(&format!(
151 "https://{domain}/v2/{namespace}{repository}{image_name}/manifests/{identifier}",
152 domain = registry_domain,
153 namespace = match image.namespace {
154 Some(ref namespace) => format!("{namespace}/"),
155 None => String::new(),
156 },
157 repository = match image.repository {
158 Some(ref repository) => format!("{repository}/"),
159 None => String::new(),
160 },
161 image_name = image.image_name.name,
162 identifier = image.image_name.identifier
163 ))
164 .map_err(Error::InvalidManifestUrl)?;
165
166 self.get_manifest_url(&url, image).await
167 }
168
169 #[tracing::instrument(skip_all)]
170 async fn get_headers(&self, image: &Image) -> Result<HeaderMap, Error> {
171 if !image.registry.needs_authentication() {
172 return Ok(HeaderMap::new());
173 }
174
175 let cache_key = image.into();
176
177 let token = self
178 .token_cache
179 .fetch(&cache_key)
180 .await
181 .map_err(Error::FetchToken)?;
182
183 let token = if let Some(token) = token {
184 token
185 } else {
186 let namespace = match &image.namespace {
187 Some(namespace) => format!("{namespace}/"),
188 None => String::new(),
189 };
190
191 let repository = match &image.repository {
192 Some(repository) => format!("{repository}/"),
193 None => String::new(),
194 };
195
196 let token_url = match image.registry {
197 Registry::Github => format!(
198 "https://ghcr.io/token?scope=repository:{namespace}{repository}{image_name}:pull&service=ghcr.io",
199 image_name = image.image_name.name
200 ),
201
202 Registry::DockerHub => format!("https://auth.docker.io/token?service=registry.docker.io&scope=repository:{namespace}{repository}{image_name}:pull&service=registry.docker.io", image_name = image.image_name.name),
203
204 Registry::Quay => format!("https://quay.io/v2/auth?scope=repository:{namespace}{repository}{image_name}:pull&service=quay.io", image_name = image.image_name.name),
205
206 Registry::RedHat | Registry::K8s | Registry::Google | Registry::Microsoft => return Ok(HeaderMap::new()),
207 };
208
209 let token_url = Url::parse(&token_url).map_err(Error::InvalidTokenUrl)?;
210
211 let response = self
212 .client
213 .get(token_url)
214 .send()
215 .instrument(info_span!("get token request"))
216 .await
217 .map_err(Error::GetToken)?;
218
219 let body = response
220 .text()
221 .instrument(info_span!("extract token request body"))
222 .await
223 .map_err(Error::ExtractTokenBody)?;
224
225 let token: Token =
226 serde_json::from_str(&body).map_err(|e| Error::DeserializeToken(e, body))?;
227
228 self.token_cache
229 .store(cache_key, token.clone())
230 .await
231 .map_err(Error::StoreToken)?;
232
233 token
234 };
235
236 let headers = token.try_into().map_err(Error::ParseAuthorizationHeader)?;
237
238 Ok(headers)
239 }
240}
241
242#[cfg(test)]
243#[expect(clippy::unwrap_used, reason = "using unwrap in tests is fine")]
244mod tests {
245 mod dockerhub {
246 use crate::{
247 Client,
248 Image,
249 ImageName,
250 Registry,
251 Tag,
252 };
253 use either::Either;
254
255 #[tokio::test]
256 async fn alpine() {
257 let client = Client::new();
258
259 let image_name = Image {
260 registry: Registry::DockerHub,
261 namespace: None,
262 repository: Some("library".to_string()),
263 image_name: ImageName {
264 name: "alpine".to_string(),
265 identifier: Either::Left(Tag::Specific("3.20".to_string())),
266 },
267 };
268
269 let response = client.get_manifest(&image_name).await.unwrap();
270
271 insta::assert_json_snapshot!(response);
272 }
273 }
274
275 mod redhat {
276 use crate::{
277 Client,
278 Image,
279 ImageName,
280 Registry,
281 Tag,
282 };
283 use either::Either;
284
285 #[tokio::test]
286 async fn ubi8() {
287 let client = Client::new();
288
289 let image = Image {
290 registry: Registry::RedHat,
291 namespace: None,
292 repository: None,
293 image_name: ImageName {
294 name: "ubi8".to_string(),
295 identifier: Either::Left(Tag::Specific("8.9".to_string())),
296 },
297 };
298
299 let response = client.get_manifest(&image).await.unwrap();
300
301 insta::assert_json_snapshot!(response);
302 }
303
304 #[tokio::test]
305 async fn cosign() {
306 const INPUT: &str = "ghcr.io/sigstore/cosign/cosign:v2.4.0";
307
308 let client = Client::new();
309 let image = INPUT.parse().unwrap();
310 let response = client.get_manifest(&image).await.unwrap();
311
312 insta::assert_json_snapshot!(response);
313 }
314
315 #[tokio::test]
316 async fn playwright() {
317 const INPUT: &str = "mcr.microsoft.com/playwright:v1.48.2-noble";
318
319 let client = Client::new();
320 let image = INPUT.parse().unwrap();
321 let response = client.get_manifest(&image).await.unwrap();
322
323 insta::assert_json_snapshot!(response);
324 }
325 }
326}